# 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:** ```env RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ### 0.2 Configure Nextcloud OAuth2 Provider **Already Done! Just need to integrate:** **Update `lib/auth.ts` (NextAuth config):** ```typescript 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:** ```env 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) ```typescript 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:** ```bash npm install @sentry/nextjs ``` **File: `sentry.client.config.js`** ```javascript 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:** ```env 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:** ```sql -- 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: ```typescript 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) ```typescript 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`** ```typescript 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 ', to: appointment.client_email, subject: 'Your Tattoo Booking Request - United Tattoo', html: `

Booking Request Received!

Hi ${appointment.client_name},

We've received your tattoo appointment request. Here are the details:

Artist: ${appointment.artist_name}

Date: ${formattedDate}

Time: ${formattedTime}

Description: ${appointment.description}

What's Next?

  1. Your artist will review your request (usually within 24 hours)
  2. You'll receive an email when your appointment is confirmed
  3. Bring a valid ID and arrive 10 minutes early

Questions? Call us at (719) 555-1234 or reply to this email.

Thanks,
United Tattoo Team

`, }) } 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 ', to: appointment.client_email, subject: config.subject, html: `

${config.message}

Hi ${appointment.client_name},

Artist: ${appointment.artist_name}

Status: ${appointment.status}

${config.action}

Questions? Call us at (719) 555-1234

`, }) } 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: ```typescript // 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 ### How to Link D1 Artists with Nextcloud Users **Option 1: Email Matching (Simple - Start Here)** ```typescript // 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)** ```sql -- 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 ```env # 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?**