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

20 KiB

Booking Workflow - Revised Implementation Plan

Version: 2.0 (Revised with Nextcloud OAuth2)
Date: January 9, 2025
Status: Ready for Implementation


Critical Architectural Decisions

1. Authentication Strategy: TWO-TIER SYSTEM

Customers (Public):

  • NO LOGIN REQUIRED
  • Anonymous booking with email + phone
  • Receive confirmation via email
  • Simple, fast, no friction

Artists & Admins (Internal):

  • Nextcloud OAuth2 authentication
  • Single sign-on (SSO)
  • Access to admin dashboards + calendars

2. Artist Data: HYBRID MODEL (No Migration Needed!)

Cloudflare D1 + R2:

  • Source of truth for artist PROFILES
  • Name, bio, specialties, portfolio images
  • Used by public website (existing code stays as-is)
  • Booking form artist selection
  • NO CHANGES TO EXISTING SYSTEM

Nextcloud:

  • Source of truth for AUTHENTICATION
  • Source of truth for CALENDAR availability
  • Artists are users in "artist" Nextcloud group

Link Between Them:

  • Simple: Match via email address
  • Robust: Add optional nextcloud_user_id to artists table
  • No complex sync needed!

3. Services Confirmed

Email: Resend (free tier - 3,000/month)

  • Domain: united-tattoos.com (owned, on Cloudflare)
  • Sender: bookings@united-tattoos.com
  • Easy ownership transfer via team feature

Monitoring:

  • Cloudflare Workers Analytics (free, built-in)
  • Sentry (free tier - 5k errors/month)

Authentication:

  • Nextcloud OAuth2 (already configured!)
  • Client ID: PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI
  • Secret: tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm
  • Base URL: https://portal.united-tattoos.com

Revised Implementation Phases

PHASE 0: Foundation Setup (NEW - FIRST!)

Duration: 1-2 days
Priority: BLOCKING - Must complete before other phases

0.1 Set Up Resend Email Service

Steps:

  1. Sign up at resend.com (free tier)
  2. Add domain united-tattoos.com
  3. Add DNS records to Cloudflare:
    TXT  resend._domainkey  [value from Resend]
    TXT  _dmarc            "v=DMARC1; p=none;"
    
  4. Verify domain
  5. Test send email
  6. Add API key to environment variables

Environment Variable:

RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

0.2 Configure Nextcloud OAuth2 Provider

Already Done! Just need to integrate:

Update lib/auth.ts (NextAuth config):

import NextAuth, { NextAuthOptions } from "next-auth"
import { D1Adapter } from "@next-auth/d1-adapter"

export const authOptions: NextAuthOptions = {
  adapter: D1Adapter(process.env.DB), // Existing D1 adapter
  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) {
        return {
          id: profile.ocs.data.id,
          name: profile.ocs.data.displayname,
          email: profile.ocs.data.email,
          image: profile.ocs.data.avatar || null,
        }
      },
    },
  ],
  callbacks: {
    async session({ session, user }) {
      // Add user role from database
      const db = getDB()
      const dbUser = await db
        .prepare('SELECT role FROM users WHERE email = ?')
        .bind(session.user.email)
        .first()
      
      session.user.id = user.id
      session.user.role = dbUser?.role || 'CLIENT'
      
      return session
    },
    async signIn({ user, account, profile }) {
      // Check if user is in Nextcloud "artist" or "admin" group
      // This can be checked via Nextcloud API if needed
      return true
    },
  },
  pages: {
    signIn: '/auth/signin',
    error: '/auth/error',
  },
}

Environment Variables:

NEXTCLOUD_CLIENT_ID=PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI
NEXTCLOUD_CLIENT_SECRET=tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm
NEXTCLOUD_BASE_URL=https://portal.united-tattoos.com

Callback URL (already configured in Nextcloud):

  • Production: https://united-tattoos.com/api/auth/callback/nextcloud
  • Dev: http://localhost:3000/api/auth/callback/nextcloud

0.3 Create Admin Middleware

File: middleware.ts (Update existing or create)

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 role
    if (path.startsWith('/admin')) {
      if (!token || (token.role !== 'SHOP_ADMIN' && token.role !== 'SUPER_ADMIN')) {
        return NextResponse.redirect(new URL('/auth/signin?callbackUrl=' + path, req.url))
      }
    }

    // Artist dashboard requires ARTIST role
    if (path.startsWith('/artist-dashboard')) {
      if (!token || token.role !== 'ARTIST') {
        return NextResponse.redirect(new URL('/auth/signin?callbackUrl=' + path, req.url))
      }
    }

    return NextResponse.next()
  },
  {
    callbacks: {
      authorized: ({ token, req }) => {
        // Allow all API routes (they handle their own auth)
        if (req.nextUrl.pathname.startsWith('/api')) {
          return true
        }
        
        // Require auth for admin/artist routes
        if (req.nextUrl.pathname.startsWith('/admin') || 
            req.nextUrl.pathname.startsWith('/artist-dashboard')) {
          return !!token
        }
        
        // All other routes are public
        return true
      },
    },
  }
)

export const config = {
  matcher: ['/admin/:path*', '/artist-dashboard/:path*', '/api/:path*'],
}

0.4 Add Sentry Error Tracking

Install:

npm install @sentry/nextjs

File: sentry.client.config.js

import * as Sentry from "@sentry/nextjs"

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,
  environment: process.env.NODE_ENV,
})

Environment Variable:

NEXT_PUBLIC_SENTRY_DSN=https://xxxxx@xxxxx.ingest.sentry.io/xxxxx

PHASE 1: Customer Booking Flow (REVISED)

Duration: 3-4 days
Changes: No customer authentication required!

1.1 Update Database Schema for Anonymous Bookings

Add to migrations:

-- Allow client_id to be NULL for anonymous bookings
-- Store email/phone directly on appointment
ALTER TABLE appointments ADD COLUMN client_email TEXT;
ALTER TABLE appointments ADD COLUMN client_phone TEXT;
ALTER TABLE appointments ADD COLUMN client_name TEXT;

-- Make client_id optional
-- (In SQLite, we'd need to recreate the table, but for now just allow NULL in app logic)

1.2 Update Booking Form (No Login Required!)

File: components/booking-form.tsx

Remove all session checks! Update to:

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault()
  
  if (!bookingEnabled) {
    toast.error('Booking temporarily unavailable')
    return
  }

  // NO SESSION CHECK - Customers book anonymously!
  
  if (!selectedArtist) {
    toast.error('Please select an artist')
    return
  }

  if (!appointmentStart || !appointmentEnd) {
    toast.error('Please select a date, time, and tattoo size')
    return
  }

  // Create booking without authentication
  createBooking.mutate({
    artistId: selectedArtist.id,
    // No clientId - this is anonymous!
    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 Appointments API for Anonymous Bookings

File: app/api/appointments/route.ts (UPDATE)

const createAppointmentSchema = z.object({
  artistId: z.string().min(1),
  clientId: z.string().optional(), // Now optional!
  clientName: z.string().min(1),   // NEW - Required
  clientEmail: z.string().email(),  // NEW - Required
  clientPhone: z.string().min(1),   // NEW - Required
  title: z.string().min(1),
  description: z.string().optional(),
  startTime: z.string().datetime(),
  endTime: z.string().datetime(),
  depositAmount: z.number().optional(),
  totalAmount: z.number().optional(),
  notes: z.string().optional(),
})

export async function POST(request: NextRequest, ...) {
  try {
    // NO AUTHENTICATION CHECK for booking creation!
    // This is intentionally public for customer bookings
    
    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 }
      )
    }

    const appointmentId = crypto.randomUUID()
    
    // Create appointment with customer contact info
    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, total_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.totalAmount || null,
      validatedData.notes || null
    ).run()

    // Fetch the created appointment
    const appointment = await db.prepare(`
      SELECT 
        a.*,
        ar.name as artist_name
      FROM appointments a
      JOIN artists ar ON a.artist_id = ar.id
      WHERE a.id = ?
    `).bind(appointmentId).first()

    // Sync to CalDAV
    try {
      await syncAppointmentToCalendar(appointment as any, context)
    } catch (syncError) {
      console.error('Failed to sync to calendar:', syncError)
    }

    // Send email confirmation to customer
    try {
      await sendBookingConfirmationEmail(appointment as any)
    } catch (emailError) {
      console.error('Failed to send confirmation email:', emailError)
    }

    return NextResponse.json({ appointment }, { status: 201 })
  } catch (error) {
    // ... error handling
  }
}

1.4 Create Email Service

File: lib/email.ts

import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export async function sendBookingConfirmationEmail(appointment: {
  id: string
  client_name: string
  client_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',
    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 {
    await resend.emails.send({
      from: 'United Tattoo <bookings@united-tattoos.com>',
      to: appointment.client_email,
      subject: 'Your Tattoo Booking Request - United Tattoo',
      html: `
        <h1>Booking Request Received!</h1>
        <p>Hi ${appointment.client_name},</p>
        <p>We've received your tattoo appointment request. Here are the details:</p>
        
        <div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
          <p><strong>Artist:</strong> ${appointment.artist_name}</p>
          <p><strong>Date:</strong> ${formattedDate}</p>
          <p><strong>Time:</strong> ${formattedTime}</p>
          <p><strong>Description:</strong> ${appointment.description}</p>
        </div>

        <h2>What's Next?</h2>
        <ol>
          <li>Your artist will review your request (usually within 24 hours)</li>
          <li>You'll receive an email when your appointment is confirmed</li>
          <li>Bring a valid ID and arrive 10 minutes early</li>
        </ol>

        <p>Questions? Call us at (719) 555-1234 or reply to this email.</p>
        
        <p>Thanks,<br>United Tattoo Team</p>
      `,
    })
  } catch (error) {
    console.error('Email send failed:', error)
    throw error
  }
}

export async function sendBookingStatusChangeEmail(appointment: {
  client_name: string
  client_email: string
  artist_name: string
  start_time: string
  status: string
}) {
  const statusMessages = {
    CONFIRMED: {
      subject: 'Your Tattoo Appointment is Confirmed!',
      message: 'Great news! Your appointment has been confirmed.',
      action: 'You can now pay your deposit at the shop or via the link below.',
    },
    CANCELLED: {
      subject: 'Appointment Update - United Tattoo',
      message: 'Unfortunately, we need to reschedule your appointment.',
      action: 'Please contact us to find a new time that works for you.',
    },
  }

  const config = statusMessages[appointment.status as keyof typeof statusMessages]
  if (!config) return // Don't send email for other statuses

  try {
    await resend.emails.send({
      from: 'United Tattoo <bookings@united-tattoos.com>',
      to: appointment.client_email,
      subject: config.subject,
      html: `
        <h1>${config.message}</h1>
        <p>Hi ${appointment.client_name},</p>
        <p><strong>Artist:</strong> ${appointment.artist_name}</p>
        <p><strong>Status:</strong> ${appointment.status}</p>
        <p>${config.action}</p>
        <p>Questions? Call us at (719) 555-1234</p>
      `,
    })
  } catch (error) {
    console.error('Status change email failed:', error)
    throw error
  }
}

1.5 Update Confirmation Page (No Auth Required)

File: app/book/confirm/[id]/page.tsx

Remove authentication check - make it public with just the booking ID:

// NO getServerSession call!
// Anyone with the link can view their confirmation

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()
  }

  // No auth check - confirmation is public!
  // Security by obscurity (UUID is hard to guess)

  return (
    // ... existing confirmation page content
  )
}

PHASE 2 & 3: Admin Dashboards (PROTECTED)

These stay mostly the same, but NOW:

  • Protected by middleware
  • Require Nextcloud OAuth2 login
  • Check user role from database

All the code from the original plan applies here, just add middleware protection!


Artist Data Linking Strategy

Option 1: Email Matching (Simple - Start Here)

// When admin configures calendar, we match by email
async function linkArtistToNextcloud(artistId: string) {
  const db = getDB()
  
  // Get artist email from D1
  const artist = await db
    .prepare('SELECT email FROM artists WHERE id = ?')
    .bind(artistId)
    .first()
  
  // Calendar URL pattern for this artist
  const calendarUrl = `https://portal.united-tattoos.com/remote.php/dav/calendars/${artist.email}/personal/`
  
  // Save configuration
  await db.prepare(`
    INSERT INTO artist_calendars (id, artist_id, calendar_url, calendar_id)
    VALUES (?, ?, ?, 'personal')
  `).bind(crypto.randomUUID(), artistId, calendarUrl).run()
}

Option 2: Add Nextcloud User ID (Robust - Later)

-- Migration: Add optional nextcloud_user_id
ALTER TABLE artists ADD COLUMN nextcloud_user_id TEXT;
CREATE INDEX idx_artists_nextcloud_user ON artists(nextcloud_user_id);

Then query Nextcloud API to get user ID and store it.

Migration Path (Non-Breaking!)

Current State:
✅ D1 artists table exists with all data
✅ R2 has portfolio images
✅ Website displays artists from D1
✅ Booking form uses artists from D1

Step 1: Create Nextcloud users for each artist
  - Email must match artists.email in D1
  - Add to "artist" group in Nextcloud

Step 2: Artists log in via OAuth2
  - They use Nextcloud credentials
  - System matches by email to D1 artist record

Step 3: Admin links calendars
  - Use calendar config UI
  - Matches D1 artist → Nextcloud calendar via email

✅ No data migration needed
✅ Existing site keeps working
✅ Just add authentication layer on top

Environment Variables Summary

# Existing
DATABASE_URL=...
NEXTAUTH_URL=https://united-tattoos.com
NEXTAUTH_SECRET=...
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_BUCKET_NAME=...

# NEW - Email
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

# NEW - Nextcloud OAuth2
NEXTCLOUD_CLIENT_ID=PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI
NEXTCLOUD_CLIENT_SECRET=tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm
NEXTCLOUD_BASE_URL=https://portal.united-tattoos.com

# NEW - CalDAV (for background sync)
NEXTCLOUD_USERNAME=admin_or_service_account
NEXTCLOUD_PASSWORD=app_password
NEXTCLOUD_CALENDAR_BASE_PATH=/remote.php/dav/calendars

# NEW - Monitoring (optional)
NEXT_PUBLIC_SENTRY_DSN=https://xxxxx@xxxxx.ingest.sentry.io/xxxxx

Testing Strategy

Phase 0 Testing

  • Resend email sends successfully
  • DNS records verified in Cloudflare
  • Nextcloud OAuth2 login works
  • Middleware protects admin routes
  • Sentry captures test error

Phase 1 Testing (No Customer Login)

  • Customer can book WITHOUT logging in
  • Booking form submits successfully
  • Customer receives confirmation email
  • Booking syncs to Nextcloud
  • Confirmation page accessible via link

Admin Testing (With Login)

  • Admin can log in via Nextcloud
  • Admin sees bookings dashboard
  • Admin can approve/reject bookings
  • Status changes sync to Nextcloud
  • Email sent on status change

Key Benefits of This Approach

No customer friction - Book in 2 minutes without account
Single sign-on for staff - One password (Nextcloud)
No artist data migration - D1 + R2 stays as-is
Clean separation - Profiles vs Authentication vs Calendar
Existing site untouched - All current features keep working
Secure - Middleware protects admin routes automatically


Next Steps

  1. Review this plan - Does the two-tier auth + hybrid data model work for you?
  2. Set up Resend - Add domain, get API key
  3. I implement Phase 0 - Foundation (OAuth2 + Email + Middleware)
  4. Test authentication - Make sure Nextcloud login works
  5. Proceed with booking flow - Anonymous customer bookings

Ready to proceed?