Skip to main content

Patient Appointment Confirm/Cancel + WhatsApp Gating — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Let patients confirm or cancel their appointment with a single click from their scheduled-email, no login required; and add a module-level guard to the WhatsApp config/logs API routes. Architecture: HMAC-SHA256 signed tokens (48 h TTL, no new DB table) are generated on the server when appointment status transitions to scheduled, embedded as confirmUrl/cancelUrl in the patient email, and resolved by a new unauthenticated public endpoint that updates status + fires existing notifications. The patient sees a simple branded landing page at portal.odontox.io/appointment/respond. WhatsApp config routes gain a middleware that returns 403 when the whatsapp_api clinic module is disabled. Tech Stack: Hono (server routes), Drizzle ORM, Node.js crypto (HMAC), React (UI landing page), react-router-dom (routing), existing ENCRYPTION_KEY env var (no new secrets needed).

File Map

FileActionResponsibility
server/src/lib/appointment-tokens.tsCreateToken generation + verification
server/src/routes/public-appointments.tsCreatePublic POST /api/v1/appointments/respond
server/src/middleware/require-module.tsCreateModule-enabled guard middleware
server/src/api.tsModifyMount public-appointments route
server/src/lib/email.tsModifyAdd confirmUrl/cancelUrl to AppointmentEmailContext + sendAppointmentScheduledEmail
server/src/emails/AppointmentScheduledEmail.tsxModifyRender Confirm button + Cancel link for patient view
server/src/lib/appointment-notifications.tsModifyGenerate tokens + pass to sendAppointmentStatusEmails
server/src/routes/whatsapp-config.tsModifyApply requireModule('whatsapp_api') middleware
ui/src/components/appointments/AppointmentRespond.tsxCreatePatient landing page
ui/src/App.tsxModifyAdd /appointment/respond lazy route (no auth)
ui/src/components/routers/SubdomainRouter.tsxModifyAllow /appointment path on portal subdomain

Task 1: Token library — server/src/lib/appointment-tokens.ts

Files:
  • Create: server/src/lib/appointment-tokens.ts
  • Step 1: Create the token library
// server/src/lib/appointment-tokens.ts
import crypto from 'node:crypto';
import { getEnv } from './env';

export class TokenExpiredError extends Error { constructor() { super('Token expired'); } }
export class TokenInvalidError extends Error { constructor() { super('Invalid token'); } }

function getTokenSecret(): Buffer {
  const encKey = getEnv('ENCRYPTION_KEY');
  if (!encKey) throw new Error('ENCRYPTION_KEY is required');
  // Derive a separate secret so appointment tokens can't be confused with encrypted data
  return crypto.createHash('sha256').update('appt-action-v1:' + encKey).digest();
}

function base64url(buf: Buffer): string {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function fromBase64url(s: string): Buffer {
  return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}

const TTL_SECONDS = 48 * 60 * 60; // 48 hours

export function generateAppointmentToken(
  appointmentId: string,
  action: 'confirm' | 'cancel',
): string {
  const expiresAt = Math.floor(Date.now() / 1000) + TTL_SECONDS;
  const payload = `${appointmentId}:${action}:${expiresAt}`;
  const secret = getTokenSecret();
  const hmac = crypto.createHmac('sha256', secret).update(payload).digest();
  return `${base64url(Buffer.from(payload))}.${base64url(hmac)}`;
}

export function verifyAppointmentToken(
  raw: string,
): { appointmentId: string; action: 'confirm' | 'cancel' } {
  const dot = raw.indexOf('.');
  if (dot === -1) throw new TokenInvalidError();

  const payloadBuf = fromBase64url(raw.slice(0, dot));
  const sigBuf = fromBase64url(raw.slice(dot + 1));

  const payload = payloadBuf.toString('utf8');
  const secret = getTokenSecret();
  const expected = crypto.createHmac('sha256', secret).update(payload).digest();

  // Constant-time compare to prevent timing attacks
  if (sigBuf.length !== expected.length || !crypto.timingSafeEqual(sigBuf, expected)) {
    throw new TokenInvalidError();
  }

  const parts = payload.split(':');
  if (parts.length !== 3) throw new TokenInvalidError();
  const [appointmentId, action, expiresAtStr] = parts;

  if (action !== 'confirm' && action !== 'cancel') throw new TokenInvalidError();
  if (Math.floor(Date.now() / 1000) > parseInt(expiresAtStr, 10)) throw new TokenExpiredError();

  return { appointmentId, action: action as 'confirm' | 'cancel' };
}
  • Step 2: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/server
npx tsc --noEmit 2>&1 | grep "appointment-tokens"
Expected: no output (no errors).
  • Step 3: Commit
cd /Users/ssh/Documents/Beta-App/odontoX
git add server/src/lib/appointment-tokens.ts
git commit -m "feat(appointments): add HMAC appointment action token library"

Task 2: Public respond endpoint — server/src/routes/public-appointments.ts

Files:
  • Create: server/src/routes/public-appointments.ts
  • Modify: server/src/api.ts
  • Step 1: Create the public route
// server/src/routes/public-appointments.ts
import { Hono } from 'hono';
import { getDatabase } from '../lib/db';
import { getDatabaseUrl } from '../lib/env';
import { appointments, patients, clinics } from '../schema';
import { eq, and } from 'drizzle-orm';
import { handleError, AppError } from '../lib/errors';
import { verifyAppointmentToken, TokenExpiredError, TokenInvalidError } from '../lib/appointment-tokens';
import { decryptPatientPHI } from '../lib/encryption';
import { queueAppointmentStatusNotifications } from '../lib/appointment-notifications';
import { getClientIp } from '../lib/turnstile';

const publicAppointmentsRoute = new Hono();

// In-memory rate limiter: 10 requests per minute per IP
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function isRateLimited(ip: string): boolean {
  const now = Date.now();
  const windowMs = 60 * 1000;
  const maxRequests = 10;
  const entry = rateLimitMap.get(ip);
  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });
    return false;
  }
  if (entry.count >= maxRequests) return true;
  entry.count++;
  return false;
}

const TERMINAL_STATUSES = ['completed', 'in_progress', 'no_show'] as const;

publicAppointmentsRoute.post('/respond', async (c) => {
  try {
    const ip = getClientIp(c);
    if (isRateLimited(ip)) {
      return c.json({ error: 'Too many requests. Please try again later.' }, 429);
    }

    const body = await c.req.json().catch(() => ({}));
    const { token } = body as { token?: string };
    if (!token || typeof token !== 'string') {
      return c.json({ error: 'Token required' }, 400);
    }

    let appointmentId: string;
    let action: 'confirm' | 'cancel';
    try {
      ({ appointmentId, action } = verifyAppointmentToken(token));
    } catch (e) {
      if (e instanceof TokenExpiredError) return c.json({ error: 'Token expired' }, 400);
      return c.json({ error: 'Invalid token' }, 400);
    }

    const db = await getDatabase(getDatabaseUrl());

    const [row] = await db
      .select({ appointment: appointments, patient: patients, clinic: clinics })
      .from(appointments)
      .leftJoin(patients, eq(appointments.patientId, patients.id))
      .leftJoin(clinics, eq(appointments.clinicId, clinics.id))
      .where(eq(appointments.id, appointmentId))
      .limit(1);

    if (!row?.appointment) throw new AppError('Appointment not found', 404);

    const { appointment, patient, clinic } = row;
    const clinicName = clinic?.name || 'Your Clinic';
    const decPat = patient ? decryptPatientPHI(patient as Record<string, any>) : null;
    const patientName = decPat ? `${decPat.firstName} ${decPat.lastName}` : 'Patient';

    const dateVal = appointment.appointmentDate as unknown;
    const dateStr = dateVal instanceof Date ? dateVal.toISOString().split('T')[0] : String(dateVal);
    const dt = new Date(`${dateStr}T${appointment.appointmentTime}`);
    const appointmentDate = dt.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
    const appointmentTime = dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });

    // If appointment is in a terminal state that this action can't change, return gracefully
    const currentStatus = appointment.status as string;
    const targetStatus = action === 'confirm' ? 'confirmed' : 'cancelled';

    if (currentStatus === targetStatus) {
      return c.json({ ok: true, alreadyProcessed: true, status: currentStatus, patientName, clinicName, appointmentDate, appointmentTime });
    }
    if ((TERMINAL_STATUSES as readonly string[]).includes(currentStatus) || currentStatus === 'cancelled') {
      return c.json({ ok: true, alreadyProcessed: true, status: currentStatus, patientName, clinicName, appointmentDate, appointmentTime });
    }

    // Update appointment status
    const [updated] = await db
      .update(appointments)
      .set({ status: targetStatus, updatedAt: new Date() })
      .where(eq(appointments.id, appointmentId))
      .returning();

    if (!updated) throw new AppError('Failed to update appointment', 500);

    // Fire notifications to staff (email + in-app) — executionCtx not available on public route,
    // so we await directly. The notification helper handles its own error logging.
    const doctorInfo = appointment.doctorId
      ? undefined // we don't have doctor details here; notifications will skip doctor email
      : undefined;

    queueAppointmentStatusNotifications({
      db,
      clinicId: appointment.clinicId,
      appointment: {
        id: appointment.id,
        appointmentDate: appointment.appointmentDate,
        appointmentTime: appointment.appointmentTime,
        appointmentType: appointment.appointmentType,
        notes: appointment.notes,
      },
      patientInfo: decPat as any,
      doctorInfo,
      status: targetStatus as any,
      executionCtx: {
        waitUntil: (p: Promise<any>) => p.catch(err => console.error('Notification error:', err)),
      },
    });

    return c.json({ ok: true, alreadyProcessed: false, status: targetStatus, patientName, clinicName, appointmentDate, appointmentTime });
  } catch (error) {
    return handleError(error, c);
  }
});

export default publicAppointmentsRoute;
  • Step 2: Mount in api.ts
Open server/src/api.ts. Find the block that imports publicReferralsRoute:
import publicReferralsRoute from './routes/public-referrals';
Add immediately after:
import publicAppointmentsRoute from './routes/public-appointments';
Then find the line:
api.route('/public-referrals', publicReferralsRoute);
Add immediately after:
api.route('/appointments', publicAppointmentsRoute);
  • Step 3: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/server
npx tsc --noEmit 2>&1 | grep -E "public-appointments|api\.ts" | grep "error"
Expected: no output.
  • Step 4: Commit
cd /Users/ssh/Documents/Beta-App/odontoX
git add server/src/routes/public-appointments.ts server/src/api.ts
git commit -m "feat(appointments): public respond endpoint for patient confirm/cancel"

Task 3: Inject tokens into the scheduled email

Files:
  • Modify: server/src/lib/email.ts
  • Modify: server/src/emails/AppointmentScheduledEmail.tsx
  • Modify: server/src/lib/appointment-notifications.ts
  • Step 1: Add confirmUrl to AppointmentEmailContext and sendAppointmentScheduledEmail
In server/src/lib/email.ts, find interface AppointmentEmailContext (line ~1854) and add two fields:
interface AppointmentEmailContext {
  appointmentId: string;
  patientName: string;
  patientEmail: string;
  doctorName?: string;
  doctorEmail?: string;
  appointmentDate: string;
  appointmentTime: string;
  appointmentType: string;
  status: AppointmentStatus;
  clinicId: string;
  clinicName: string;
  reason?: string;
  notes?: string;
  location?: string;
  confirmUrl?: string;   // ← add
  cancelUrl?: string;    // ← add (replaces the stub pointing to /login)
}
Find interface SendAppointmentScheduledEmailOptions (line ~1940) and add:
interface SendAppointmentScheduledEmailOptions {
  email: string;
  patientName: string;
  doctorName?: string;
  appointmentDate: string;
  appointmentTime: string;
  appointmentType: string;
  clinicName: string;
  clinicId: string;
  location?: string;
  isForDoctor?: boolean;
  confirmUrl?: string;   // ← add
  cancelUrl?: string;    // ← add
}
Find sendAppointmentScheduledEmail (line ~1956). Update the destructure line:
const { email, patientName, doctorName, appointmentDate, appointmentTime, appointmentType, clinicName, clinicId, location, isForDoctor, confirmUrl, cancelUrl } = options;
Replace the two hardcoded URL lines:
// REMOVE these two lines:
const portalUrl = `${authUrl}/login`;
const cancelUrl = `${authUrl}/login`; // They can cancel from portal

// ADD:
const portalUrl = confirmUrl || `${authUrl}/login`;
const resolvedCancelUrl = cancelUrl || undefined;
In the render(AppointmentScheduledEmail({...})) call, update the props:
const html = await render(AppointmentScheduledEmail({
  branding,
  patientName,
  doctorName: doctorName || '',
  appointmentDate,
  appointmentTime,
  appointmentType,
  location,
  portalUrl,
  cancelUrl: isForDoctor ? undefined : resolvedCancelUrl,
  confirmUrl: isForDoctor ? undefined : confirmUrl,    // ← add
  isForDoctor,
}));
In sendAppointmentStatusEmails (line ~2252), in the case 'scheduled': patient send:
case 'scheduled':
  await sendAppointmentScheduledEmail({
    email: patientEmail,
    patientName,
    doctorName,
    appointmentDate,
    appointmentTime,
    appointmentType,
    clinicName,
    clinicId,
    location,
    isForDoctor: false,
    confirmUrl: context.confirmUrl,   // ← add
    cancelUrl: context.cancelUrl,     // ← add
  });
  • Step 2: Update the email template to render Confirm + Cancel buttons
Open server/src/emails/AppointmentScheduledEmail.tsx. Update the interface and patient template:
interface AppointmentScheduledEmailProps {
    branding: ClinicBranding;
    patientName: string;
    doctorName: string;
    appointmentDate: string;
    appointmentTime: string;
    appointmentType: string;
    location?: string;
    portalUrl: string;
    cancelUrl?: string;
    confirmUrl?: string;   // ← add
    isForDoctor?: boolean;
}
Add confirmUrl to the destructure:
export const AppointmentScheduledEmail = ({
    branding,
    patientName,
    doctorName,
    appointmentDate,
    appointmentTime,
    appointmentType,
    location,
    portalUrl,
    cancelUrl,
    confirmUrl,    // ← add
    isForDoctor = false,
}: AppointmentScheduledEmailProps) => {
Replace the patient view’s button section (lines ~83-95):
<Section style={s.buttonSection}>
    {confirmUrl ? (
        <ClinicButton href={confirmUrl} color={accentColor}>
            Confirm Appointment
        </ClinicButton>
    ) : (
        <ClinicButton href={portalUrl} color={accentColor}>
            View Appointment
        </ClinicButton>
    )}
</Section>

{cancelUrl && (
    <Text style={{ ...s.footnote, textAlign: 'center' as const }}>
        Need to cancel?{' '}
        <a href={cancelUrl} style={{ color: '#6b7280' }}>
            Cancel this appointment
        </a>
        {' '}(please cancel at least 48 hours in advance)
    </Text>
)}
  • Step 3: Generate tokens in appointment-notifications.ts
Open server/src/lib/appointment-notifications.ts. Add the import at the top:
import { generateAppointmentToken } from './appointment-tokens';
import { getEnv } from './env';
Find the block that calls sendAppointmentStatusEmails (inside the scheduled patient email path, around line 161). Replace the call:
if (patientInfo?.email) {
  try {
    // Generate confirm/cancel tokens for patient email (only for scheduled status)
    const portalBase = getEnv('APP_URL') || 'https://portal.odontox.io';
    const confirmUrl = status === 'scheduled'
      ? `${portalBase}/appointment/respond?token=${generateAppointmentToken(appointment.id, 'confirm')}`
      : undefined;
    const cancelUrl = status === 'scheduled'
      ? `${portalBase}/appointment/respond?token=${generateAppointmentToken(appointment.id, 'cancel')}`
      : undefined;

    await sendAppointmentStatusEmails({
      appointmentId: appointment.id,
      patientName: `${patientInfo.firstName} ${patientInfo.lastName}`,
      patientEmail: patientInfo.email,
      doctorName: doctorInfo ? `Dr. ${doctorInfo.firstName} ${doctorInfo.lastName}` : undefined,
      doctorEmail: doctorInfo?.email || undefined,
      appointmentDate: formattedDate,
      appointmentTime: formattedTime,
      appointmentType: appointment.appointmentType || 'Appointment',
      status,
      clinicId,
      clinicName,
      reason: reason || undefined,
      notes: appointment.notes || undefined,
      confirmUrl,
      cancelUrl,
    });
  } catch (emailError) {
    console.error('Error sending appointment status emails:', emailError);
  }
}
  • Step 4: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/server
npx tsc --noEmit 2>&1 | grep -E "email\.ts|AppointmentScheduled|appointment-notifications" | grep "error"
Expected: no output.
  • Step 5: Commit
cd /Users/ssh/Documents/Beta-App/odontoX
git add server/src/lib/email.ts server/src/emails/AppointmentScheduledEmail.tsx server/src/lib/appointment-notifications.ts
git commit -m "feat(appointments): inject confirm/cancel token URLs into scheduled patient email"

Task 4: WhatsApp module guard middleware

Files:
  • Create: server/src/middleware/require-module.ts
  • Modify: server/src/routes/whatsapp-config.ts
  • Step 1: Create the middleware
// server/src/middleware/require-module.ts
import { Context, Next } from 'hono';
import { getDatabase } from '../lib/db';
import { getDatabaseUrl } from '../lib/env';
import { clinicModules } from '../schema';
import { and, eq } from 'drizzle-orm';

export function requireModule(moduleKey: string) {
  return async (c: Context, next: Next) => {
    const clinicContext = c.get('clinicContext') as { currentClinicId?: string } | undefined;
    const clinicId = clinicContext?.currentClinicId;
    if (!clinicId) return c.json({ error: 'No clinic context' }, 403);

    try {
      const db = await getDatabase(getDatabaseUrl());
      const [row] = await db
        .select({ id: clinicModules.id })
        .from(clinicModules)
        .where(
          and(
            eq(clinicModules.clinicId, clinicId),
            eq(clinicModules.moduleKey, moduleKey),
            eq(clinicModules.isEnabled, true),
          )
        )
        .limit(1);

      if (!row) {
        return c.json({ error: `Module '${moduleKey}' is not enabled for this clinic` }, 403);
      }
    } catch {
      return c.json({ error: 'Failed to verify module access' }, 500);
    }

    await next();
  };
}
  • Step 2: Apply to WhatsApp config routes
Open server/src/routes/whatsapp-config.ts. Add import after existing imports:
import { requireModule } from '../middleware/require-module';
Find the line (line ~12):
whatsappConfigRoute.use('*', requireClinicContext);
Add immediately after:
whatsappConfigRoute.use('*', requireModule('whatsapp_api'));
  • Step 3: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/server
npx tsc --noEmit 2>&1 | grep -E "require-module|whatsapp-config" | grep "error"
Expected: no output.
  • Step 4: Commit
cd /Users/ssh/Documents/Beta-App/odontoX
git add server/src/middleware/require-module.ts server/src/routes/whatsapp-config.ts
git commit -m "feat(whatsapp): requireModule middleware + apply to whatsapp-config routes"

Task 5: Patient landing page UI

Files:
  • Create: ui/src/components/appointments/AppointmentRespond.tsx
  • Modify: ui/src/App.tsx
  • Modify: ui/src/components/routers/SubdomainRouter.tsx
  • Step 1: Create the landing page component
// ui/src/components/appointments/AppointmentRespond.tsx
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CheckCircle2, XCircle, AlertTriangle, Loader2, Calendar } from 'lucide-react';

interface RespondResult {
  ok: boolean;
  alreadyProcessed?: boolean;
  status: string;
  patientName: string;
  clinicName: string;
  appointmentDate: string;
  appointmentTime: string;
}

type PageState = 'loading' | 'confirmed' | 'cancelled' | 'already' | 'error';

export default function AppointmentRespond() {
  const [searchParams] = useSearchParams();
  const [state, setState] = useState<PageState>('loading');
  const [result, setResult] = useState<RespondResult | null>(null);
  const [errorMsg, setErrorMsg] = useState('');

  useEffect(() => {
    const token = searchParams.get('token');
    if (!token) {
      setErrorMsg('No token provided. This link may be incomplete.');
      setState('error');
      return;
    }

    const apiBase = import.meta.env.VITE_API_URL || 'https://api.odontox.io/api/v1';

    fetch(`${apiBase}/appointments/respond`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token }),
    })
      .then(async (res) => {
        const data = await res.json();
        if (!res.ok) {
          const msg = data?.error || 'Something went wrong.';
          if (msg.toLowerCase().includes('expired')) {
            setErrorMsg('This link has expired. Please contact the clinic to reschedule.');
          } else {
            setErrorMsg(msg);
          }
          setState('error');
          return;
        }
        setResult(data);
        if (data.alreadyProcessed) {
          setState('already');
        } else if (data.status === 'confirmed') {
          setState('confirmed');
        } else {
          setState('cancelled');
        }
      })
      .catch(() => {
        setErrorMsg('Unable to reach the server. Please try again or contact the clinic.');
        setState('error');
      });
  }, []);

  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
      <div className="bg-white rounded-2xl shadow-lg max-w-md w-full p-8 text-center">

        {state === 'loading' && (
          <>
            <Loader2 className="h-12 w-12 animate-spin text-primary mx-auto mb-4" />
            <h1 className="text-xl font-semibold text-gray-800">Processing your response…</h1>
            <p className="text-gray-500 mt-2 text-sm">Just a moment.</p>
          </>
        )}

        {state === 'confirmed' && result && (
          <>
            <div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
              <CheckCircle2 className="h-8 w-8 text-emerald-600" />
            </div>
            <h1 className="text-2xl font-bold text-gray-900">Appointment Confirmed</h1>
            <p className="text-gray-600 mt-2">
              Hi {result.patientName}, your appointment is confirmed.
            </p>
            <div className="mt-5 rounded-xl bg-gray-50 p-4 text-left space-y-2">
              <div className="flex items-center gap-2 text-sm text-gray-700">
                <Calendar className="h-4 w-4 text-primary shrink-0" />
                <span>{result.appointmentDate} at {result.appointmentTime}</span>
              </div>
              <p className="text-xs text-gray-500 pl-6">{result.clinicName}</p>
            </div>
            <p className="text-sm text-gray-500 mt-5">We look forward to seeing you!</p>
          </>
        )}

        {state === 'cancelled' && result && (
          <>
            <div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-4">
              <XCircle className="h-8 w-8 text-gray-500" />
            </div>
            <h1 className="text-2xl font-bold text-gray-900">Appointment Cancelled</h1>
            <p className="text-gray-600 mt-2">
              Hi {result.patientName}, your appointment has been cancelled.
            </p>
            <p className="text-sm text-gray-500 mt-4">
              If you'd like to reschedule, please contact{' '}
              <strong>{result.clinicName}</strong> directly.
            </p>
          </>
        )}

        {state === 'already' && result && (
          <>
            <div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-4">
              <CheckCircle2 className="h-8 w-8 text-blue-600" />
            </div>
            <h1 className="text-xl font-bold text-gray-900">Already Recorded</h1>
            <p className="text-gray-600 mt-2">
              Your response was already recorded (status: <strong>{result.status}</strong>).
            </p>
            <p className="text-sm text-gray-500 mt-3">No further action needed.</p>
          </>
        )}

        {state === 'error' && (
          <>
            <div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
              <AlertTriangle className="h-8 w-8 text-amber-600" />
            </div>
            <h1 className="text-xl font-bold text-gray-900">Link Invalid</h1>
            <p className="text-gray-600 mt-2 text-sm">{errorMsg}</p>
          </>
        )}

        <p className="text-[11px] text-gray-400 mt-8">Powered by OdontoX</p>
      </div>
    </div>
  );
}
  • Step 2: Register the route in App.tsx
Open ui/src/App.tsx. Add the lazy import alongside existing lazy imports (around line 40):
const AppointmentRespond = lazy(() => import('@/components/appointments/AppointmentRespond'));
Find the /share/:token public route (around line 640):
<Route
  path="/share/:token"
  element={<PublicDocumentPage />}
/>
Add the new route immediately after:
<Route
  path="/appointment/respond"
  element={<AppointmentRespond />}
/>
  • Step 3: Allow /appointment path on the portal subdomain
Open ui/src/components/routers/SubdomainRouter.tsx. Find the portal subdomain block (around line 72):
else if (subdomain === SUBDOMAINS.PORTAL) {
    if (!path.startsWith('/share')) {
        setIsRedirecting(true);
        window.location.href = `${protocol}//${SUBDOMAINS.DASHBOARD}.${rootDomain}/dashboard`;
        return;
    }
}
Replace with:
else if (subdomain === SUBDOMAINS.PORTAL) {
    if (!path.startsWith('/share') && !path.startsWith('/appointment')) {
        setIsRedirecting(true);
        window.location.href = `${protocol}//${SUBDOMAINS.DASHBOARD}.${rootDomain}/dashboard`;
        return;
    }
}
  • Step 4: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/ui
npx tsc --noEmit 2>&1 | grep -E "AppointmentRespond|SubdomainRouter|App\.tsx" | grep "error"
Expected: no output.
  • Step 5: Commit
cd /Users/ssh/Documents/Beta-App/odontoX
git add ui/src/components/appointments/AppointmentRespond.tsx ui/src/App.tsx ui/src/components/routers/SubdomainRouter.tsx
git commit -m "feat(ui): patient appointment respond landing page"

Task 6: Deploy + smoke test

  • Step 1: Deploy server
cd /Users/ssh/Documents/Beta-App/odontoX/server
npx wrangler deploy 2>&1 | tail -8
Expected: Deployed odonto-prod triggers with no errors.
  • Step 2: Build and deploy UI
cd /Users/ssh/Documents/Beta-App/odontoX/ui
npm run build 2>&1 | tail -3
Expected: ✓ built in XX.XXs
npx wrangler pages deploy dist --project-name odonto-prod-ui --branch main --commit-dirty=true 2>&1 | tail -5
  • Step 3: Force-promote canonical
ACCT=9da8a2bb48668ff798b91bd00e9ae005
TOKEN=$(grep 'oauth_token' ~/Library/Preferences/.wrangler/config/default.toml | sed 's/.*"\(.*\)"/\1/')
PROJECT=odonto-prod-ui
LATEST=$(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT" \
  -H "Authorization: Bearer $TOKEN" | \
  python3 -c "import sys,json;print(json.load(sys.stdin)['result']['latest_deployment']['id'])")
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT/deployments/$LATEST/rollback" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" | \
  python3 -c "
import sys, json; d = json.load(sys.stdin)
if d.get('result'): print('✅ Promoted:', d['result']['id'])
elif 'currently in production' in str(d.get('errors','')): print('✅ Already canonical')
else: print('❌', d.get('errors'))
"
  • Step 4: Smoke-test the public endpoint
Generate a test token (run in Node.js against the server/src/lib/appointment-tokens.ts logic or use a real appointment ID from the DB). Then:
# Replace <REAL_APPOINTMENT_ID> with any valid appointment ID from your DB
# The token below is illustrative — generate one from the token library
curl -s -X POST https://api.odontox.io/api/v1/appointments/respond \
  -H "Content-Type: application/json" \
  -d '{"token":"INVALID_TOKEN_FOR_ERROR_TEST"}' | python3 -m json.tool
Expected: {"error": "Invalid token"}
  • Step 5: Verify domain hashes
for domain in go.odontox.io portal.odontox.io odontox.io; do
  echo -n "$domain: "
  curl -s "https://$domain/" -H "Accept: text/html" | grep -o 'src="/assets/[^"]*\.js"' | head -1
done
Expected: all three show the same hash.
  • Step 6: Add RELEASES.md entry and final commit
Append to RELEASES.md:
## [2026-04-30] — Patient appointment confirm/cancel links + WhatsApp add-on gating

### What's new
- **Patients can now confirm or cancel their appointment directly from the appointment email** without logging in. The scheduled appointment email includes a "Confirm Appointment" button and a "Cancel" text link. Clicking either opens a simple branded page at portal.odontox.io that records the response and notifies the clinic instantly.

### Fixed
- **WhatsApp configuration API routes now return 403 for clinics that don't have the WhatsApp add-on module enabled**, preventing unauthorized access even if the route URL is known.

### Internal / Technical
- Appointment action tokens are HMAC-SHA256 signed, 48-hour TTL, derived from the existing ENCRYPTION_KEY — no new secrets or database tables.
- The public respond endpoint is rate-limited (10 req/min per IP), handles already-processed appointments gracefully, and fires existing in-app + email notifications to staff on success.
- `requireModule(moduleKey)` middleware added for reuse across any future add-on gating needs.

### Affected areas
- UI: yes — new portal.odontox.io/appointment/respond page
- Backend: yes — new public endpoint, token library, module middleware
- Bridge: no
cd /Users/ssh/Documents/Beta-App/odontoX
git add RELEASES.md
git commit -m "docs: release notes for patient respond + WhatsApp gating"