# Publishing Feature Design

**Date:** 2025-11-19
**Status:** Approved for Implementation
**Scope:** MVP - ISR-based publishing with floating publish button

---

## Overview

Enable dealers to publish changes to their live sites through an intuitive dashboard interface. Uses Next.js Incremental Static Regeneration (ISR) to generate static HTML on-demand, providing fast page loads with minimal database queries while maintaining the ability to update content.

## Goals

- **Static Performance:** Serve dealer pages as cached HTML with zero database queries after generation
- **Easy Updates:** One-click publishing from dashboard with clear feedback
- **Minimal Confusion:** Prevent dealers from thinking unsaved editor changes are published
- **Graceful Errors:** Handle ISR failures without alarming dealers unnecessarily
- **Support-Friendly:** Reduce support tickets through clear UI states and error messages

## Non-Goals (Out of Scope for MVP)

- DNS automation (separate Cloudflare feature, already planned)
- Mass regeneration of all dealer sites (Phase 2 - admin feature)
- Draft/published data separation (using single source of truth)
- Advanced editors beyond basic contact info (separate feature branch)
- Custom domain support (future enhancement)

---

## Architecture

### Publishing Flow

```
Registration → First Publish → Site Live at subdomain.myamsoil.com
                     ↓
Dashboard → Edit Contact Info → Save Changes → Database Updated
                     ↓
Dashboard → Floating "Publish Changes" Button → Trigger ISR → Site Regenerated
```

### Key Components

1. **Publish API Endpoint:** `/api/dealers/[id]/publish`
   - Handles both first-time and subsequent publishes
   - Updates `lastPublishedAt` timestamp
   - Triggers ISR revalidation
   - Returns success/error status

2. **ISR Revalidation System:**
   - Helper: `lib/isr-revalidation.ts`
   - API endpoint: `/app/api/revalidate/route.ts`
   - Triggers `revalidatePath(/dealers/${subdomain})`
   - Non-blocking (failures don't prevent publish success)

3. **Dealer Page:** `/app/dealers/[subdomain]/page.tsx`
   - Uses `revalidate: false` (cache forever, regenerate only on-demand)
   - `generateStaticParams()` pre-generates active dealers at build time
   - Fetches minimal data from Dealer model
   - Renders DealerTemplate component

4. **Floating Publish Button:**
   - React component on dashboard
   - Bottom-right fixed position
   - Shows when `dealer.updatedAt > dealer.lastPublishedAt`
   - Hidden when inside editors (prevents confusion)

### Data Flow

- All editors save directly to `Dealer` table (single source of truth)
- Prisma automatically updates `dealer.updatedAt` on any save
- Publishing manually sets `dealer.lastPublishedAt = now()`
- Comparing timestamps determines if publish button should appear
- ISR reads latest data from `Dealer` table when regenerating

---

## Database Schema Changes

### Add to Dealer Model

```prisma
model Dealer {
  // ... existing fields ...

  // Publishing tracking
  lastPublishedAt  DateTime?  // Timestamp of last successful publish

  // ... rest of model ...
}
```

**Migration:**

```bash
npx prisma migrate dev --name add_last_published_at
```

**Usage:**

- Set to `now()` when publish succeeds
- Compare with `updatedAt` to detect unpublished changes
- Display "Last published: X hours ago" in admin views

---

## Publish Endpoint Logic

### Endpoint: `POST /api/dealers/[id]/publish`

**Request Body:**

```json
{
  "sessionId": "cs_xxx" // Stripe checkout session ID (optional for subsequent publishes)
}
```

**Response:**

```json
{
  "success": true,
  "siteUrl": "https://subdomain.myamsoil.com",
  "message": "Site published successfully"
}
```

### Flow

1. **Authentication & Authorization**
   - Verify NextAuth session exists
   - Confirm `dealer.userId === session.user.id`
   - Rate limit: 5 requests per minute per user

2. **Validation**
   - Verify dealer exists and has subdomain
   - For first publish: verify Stripe payment completed
   - For subsequent: skip payment check

3. **Trigger ISR Revalidation**
   - Call `triggerRevalidation(subdomain)`
   - Non-blocking: fire request, continue immediately
   - Log failures but don't block publish
   - ISR will regenerate on next page visit if this fails

4. **Update Database**
   - Set `dealer.status = 'active'` (if first publish)
   - Set `dealer.lastPublishedAt = now()`
   - Commit transaction

5. **Response**
   - Return success with site URL
   - Client shows "✓ Published!" after 3 seconds

### Error Handling

| Error Type               | Behavior                    | User Experience                                                      |
| ------------------------ | --------------------------- | -------------------------------------------------------------------- |
| ISR revalidation failure | Log warning, report success | "✓ Published!" (graceful degradation, will regenerate on next visit) |
| Database failure         | Return 500 error            | "Publish failed - please try again" with [Retry] button              |
| Network timeout          | Return 408 error            | "Request timed out" with [Retry] button                              |
| Authorization failure    | Return 403 error            | "Forbidden - you don't own this dealer account"                      |

**Rollback Strategy:**

- ISR failures: No rollback needed (non-critical)
- Database failures: Show error, dealer can retry

---

## Dashboard UI - Floating Publish Button

### Location

Bottom-right corner of dashboard, fixed position (follows scroll)

### Visual States

#### 1. Hidden

- **When:** No unpublished changes (`updatedAt <= lastPublishedAt`)
- **Or:** User is inside an editor
- **Display:** Button not rendered

#### 2. Ready to Publish

- **When:** Unpublished changes exist (`updatedAt > lastPublishedAt`)
- **Display:** Blue/orange button with text "Publish Changes"
- **Badge:** Optional change indicator (dot or count)
- **Hover:** Tooltip "Make your saved changes live"

#### 3. Publishing

- **When:** API request in progress
- **Display:** "Publishing... (~3 seconds)"
- **Animation:** Spinner
- **State:** Button disabled (prevent double-click)

#### 4. Published

- **When:** API returns success
- **Display:** "✓ Published!" with green checkmark
- **Duration:** 2 seconds, then hide
- **Action:** Show "View Live Site →" link

#### 5. Error

- **When:** API returns error
- **Display:** "Update failed" with warning icon
- **Color:** Red/warning
- **Action:** [Retry] button visible
- **Persistence:** Remains until retry succeeds

### Behavior Rules

- Button hidden when `pathname.includes('/editors/')` or similar
- Only one publish request at a time (disable during publishing)
- No auto-retry on failure (user must click Retry)
- Keyboard shortcut: Cmd/Ctrl + P (optional enhancement)

### Adjacent UI Elements

- **"View Live Site" link:** Always visible next to button if `status === 'active'`
- **Opens:** `https://${subdomain}.myamsoil.${domain}` in new tab
- **Icon:** External link icon (↗)

---

## ISR Implementation

### Dealer Page Configuration

**File:** `/app/dealers/[subdomain]/page.tsx`

```typescript
export const revalidate = false; // Cache forever
export const dynamic = 'force-static'; // Generate at build/revalidation time

export async function generateStaticParams() {
  // Pre-generate pages for all active dealers at build time
  const dealers = await prisma.dealer.findMany({
    where: {
      status: 'active',
      subdomain: { not: null }
    },
    select: { subdomain: true }
  });

  return dealers.map(d => ({ subdomain: d.subdomain! }));
}

export default async function DealerPage({
  params
}: {
  params: { subdomain: string }
}) {
  // Fetch minimal dealer data
  const dealer = await prisma.dealer.findUnique({
    where: { subdomain: params.subdomain },
    select: {
      subdomain: true,
      businessName: true,
      phone: true,
      address: true,
      city: true,
      state: true,
      zip: true,
      country: true,
      domain: true,
      user: {
        select: { email: true }
      }
    }
  });

  if (!dealer) notFound();

  return <DealerTemplate dealer={transformToTemplateProps(dealer)} />;
}
```

### When ISR Regenerates

1. **Build time:** All existing active dealers pre-generated via `generateStaticParams()`
2. **First visitor:** New dealer pages generated on-demand
3. **On publish:** Explicit `revalidatePath()` triggers immediate regeneration
4. **Never expires:** `revalidate: false` means no time-based revalidation

### Performance Characteristics

- **Cached page load:** Near-instant (served from disk)
- **Database queries after generation:** Zero
- **Regeneration time:** ~2-3 seconds (only happens on publish)
- **Concurrent regenerations:** Handled by Next.js queue

---

## Editor Save vs Publish Flow

### Problem Statement

Dealers editing in a form might see "Publish Changes" and think clicking it will save their current form data, but if the editor requires manual save, their changes won't be included.

### Solution: Hide Publish Button in Editors

**Implementation:**

- Each editor is a separate route (e.g., `/dashboard/editors/contact`)
- Floating publish button checks `pathname.includes('/editors/')`
- If true, button is hidden
- If false (on main dashboard), button is visible

**User Flow:**

1. Dealer on dashboard sees "Publish Changes" button (if changes exist)
2. Dealer clicks "Edit Contact Info" → enters editor
3. Floating button **hides** (no confusion)
4. Dealer makes changes, clicks "Save Changes" in editor
5. Editor closes, returns to dashboard
6. Floating button **reappears** showing "Publish Changes"
7. Dealer clicks "Publish Changes" → ISR regenerates with saved data

**Why This Works:**

- Clear separation: Save happens in editor, Publish happens on dashboard
- No confusion about what will be published
- Consistent with draft-then-publish mental model

---

## Error Handling & Retry Logic

### Error Scenarios

#### 1. ISR Revalidation Failure

**Cause:** Next.js server busy, network timeout, internal error

**Behavior:**

- Log warning to server logs
- Report success to user (graceful degradation)
- Show "✓ Published!" (optimistic UI)

**Fallback:**

- Page regenerates on next visitor request
- Dealer can manually visit site to trigger regeneration

**Why:** ISR failures are non-critical - the page will regenerate eventually

---

#### 2. Database Update Failure

**Cause:** Connection timeout, constraint violation, Prisma error

**Behavior:**

- Return 500 error to client
- Show error message with retry option

**User Experience:**

- "Publish failed - please try again. (Error: DB-002)"
- [Retry] button visible
- Error persists until retry succeeds

**Logging:**

- Log full error with dealer ID, user ID, timestamp
- Include stack trace for debugging

---

#### 3. Network Timeout

**Cause:** Slow connection, API timeout (>10 seconds)

**Behavior:**

- Return 408 error
- Show timeout message

**User Experience:**

- "Request timed out. [Retry]"
- Clear retry button

**Configuration:**

- Client timeout: 10 seconds
- Server timeout: 30 seconds (allows for slow ISR)

---

#### 4. Authorization Failure

**Cause:** User tries to publish another user's dealer site

**Behavior:**

- Return 403 error immediately
- Log security event

**User Experience:**

- "Forbidden - you don't own this dealer account"
- No retry option (requires fixing permissions)

---

### Retry Strategy

- **Automatic retries:** None (user-initiated only)
- **Retry UI:** [Retry] button on error states
- **Backoff:** None needed (user controls timing)
- **Max retries:** Unlimited (rate-limited at 5/minute)
- **Idempotency:** Publishing same data multiple times is safe

---

### Monitoring & Alerting

**Metrics to Track:**

- Publish success rate (target: >99%)
- ISR revalidation success rate (target: >95%)
- Average publish latency (target: <3 seconds)
- Error rate by type (DNS, ISR, database, network)

**Logging:**

- All publish attempts (success/failure)
- ISR revalidation outcomes
- Retry attempts
- Authorization failures (security monitoring)

**Alerts:**

- ISR failure rate >5% for 5 minutes
- Publish error rate >1% for 5 minutes
- Database connection failures

---

## Testing Strategy

### Unit Tests

#### 1. ISR Revalidation Helper

**File:** `lib/__tests__/isr-revalidation.test.ts`

Tests:

- Successful revalidation returns `{ success: true }`
- Failed revalidation logs warning but doesn't throw
- Handles network timeouts gracefully
- Constructs correct API endpoint URL

#### 2. Publish Endpoint (Simplified)

**File:** `app/api/dealers/[id]/publish/__tests__/route.test.ts`

Tests:

- Updates `lastPublishedAt` on successful publish
- Triggers ISR revalidation (mocked)
- Validates dealer ownership (authorization)
- Rate limits excessive requests
- Returns 404 for non-existent dealer
- Returns 403 for unauthorized user

### Integration Tests

#### 1. Publish Flow Without DNS

**Scenario:** Dealer with existing subdomain publishes changes

Steps:

1. Create active dealer in test database (subdomain already set)
2. Mock ISR revalidation endpoint
3. Call publish endpoint as authenticated user
4. Verify `lastPublishedAt` timestamp updated
5. Verify ISR trigger called with correct subdomain
6. Verify response includes site URL

#### 2. ISR Page Generation

**Scenario:** Dealer page renders with correct data

Steps:

1. Create active dealer with full contact info
2. Request `/dealers/[subdomain]` page
3. Verify HTML generated with correct data
4. Verify database query uses correct select fields
5. Test 404 for non-existent subdomain

### Manual Testing Checklist

Pre-deployment testing:

- [ ] Complete registration flow → first publish
- [ ] Edit contact info → save → verify publish button appears
- [ ] Click publish → verify "Publishing..." state
- [ ] Wait 3 seconds → verify "✓ Published!" state
- [ ] Open live site → verify changes reflected
- [ ] Enter editor → verify publish button hidden
- [ ] Exit editor → verify publish button reappears
- [ ] Test error scenario (disconnect network) → verify retry works
- [ ] Test with multiple dealer accounts (no cross-contamination)
- [ ] Verify rate limiting (5 publishes in quick succession)
- [ ] Check server logs for ISR failures
- [ ] Measure ISR regeneration time (should be <3 seconds)

### Performance Testing

**Metrics to measure:**

- ISR generation time (target: <3 seconds)
- Cached page load time (target: <100ms)
- Database query time during ISR (target: <200ms)
- Concurrent publish handling (5 dealers publishing simultaneously)

---

## Implementation Phases

### Phase 1: Core Publishing (MVP)

1. Add `lastPublishedAt` field to Dealer model
2. Create ISR revalidation helper and API endpoint
3. Update publish endpoint to trigger ISR
4. Build floating publish button component
5. Implement hide-in-editor logic
6. Add error handling and retry UI
7. Write tests (unit + integration)
8. Manual testing and bug fixes

**Deliverable:** Dealers can publish changes from dashboard, see clear feedback, and recover from errors

### Phase 2: Enhanced Publishing (Post-MVP)

1. Admin "Rebuild All Sites" function for template updates
2. Publish history/audit log
3. Preview changes before publishing
4. Scheduled publishing
5. A/B testing framework

**Not in scope for initial design**

---

## Edge Cases & Considerations

### 1. First Publish vs Subsequent Publish

**Handled by:** Checking if dealer status is already 'active'

- First publish: Sets status to 'active' + updates timestamp
- Subsequent: Only updates timestamp

### 2. Multiple Edits Before Publishing

**Handled by:** Single `updatedAt` timestamp comparison

- Dealer makes 5 edits → saves 5 times
- `updatedAt` keeps updating
- Publish button shows "Publish Changes" (doesn't track individual changes)
- One publish regenerates page with all saved changes

### 3. Publishing While Another Publish In Progress

**Handled by:** Button disabled during publishing

- Click "Publish Changes"
- Button becomes disabled with spinner
- Second click does nothing
- Re-enables after success/error

### 4. ISR Regeneration Takes Longer Than Expected

**Handled by:** Non-blocking request with timeout

- Show "Publishing... (~3 seconds)" message
- Client waits up to 10 seconds for response
- If timeout, show error with retry
- ISR continues in background if request times out

### 5. Dealer Changes Subdomain After Publishing

**Considered but out of scope for MVP**

- Subdomain changes require new DNS record (separate feature)
- For MVP: Subdomain is immutable after first publish
- Future: Handle subdomain migrations with redirects

### 6. Global Template Updates (AMSOIL Marketing Content)

**Deferred to Phase 2**

- MVP: Template updates require manual intervention or redeployment
- Phase 2: Admin "Rebuild All Sites" button
- Frequency: Likely quarterly or less (acceptable manual process initially)

---

## Success Metrics

### Launch Criteria (MVP)

- [ ] All unit tests passing
- [ ] Integration tests passing
- [ ] Manual testing checklist complete
- [ ] ISR generation time consistently <3 seconds
- [ ] Zero critical bugs in staging environment
- [ ] Documentation complete (this design doc + implementation plan)

### Post-Launch Metrics (30 days)

- Publish success rate >99%
- ISR revalidation success rate >95%
- Average support tickets related to publishing: <5 per week
- Dealer satisfaction (survey): "Publishing is easy to understand" >90% agree
- Zero critical publishing bugs reported

---

## Future Enhancements (Out of Scope)

1. **Draft/Published Data Separation**
   - Separate columns for draft vs published content
   - Preview functionality before publishing
   - Rollback to previous published version

2. **Mass Regeneration**
   - Admin button to rebuild all 3500+ dealer sites
   - Progress tracking UI
   - Batch processing to avoid server overload

3. **Scheduled Publishing**
   - Queue changes to go live at specific time
   - Timezone-aware scheduling
   - Email notification when changes go live

4. **Content Versioning**
   - Track history of published versions
   - Diff view between versions
   - One-click rollback to previous version

5. **Advanced Editors**
   - Rich text editor for about section
   - Image upload and cropping
   - Hero slider customization
   - Custom color themes

---

## Related Documentation

- **Cloudflare DNS Automation:** `docs/plans/2025-01-19-cloudflare-subdomain-automation.md`
- **Cloudflare Design:** `docs/plans/2025-01-19-cloudflare-subdomain-automation-design.md`
- **Basic Dealer Editor:** Feature branch `feature/basic-dealer-editor`
- **DealerTemplate Wireframe:** `components/DealerTemplate.tsx`
- **Registration Flow:** `docs/REGISTRATION_FLOW_MAP.md`

---

## Appendix: API Contracts

### Publish Endpoint

**Request:**

```http
POST /api/dealers/[id]/publish
Content-Type: application/json
Authorization: Bearer {nextauth-session-token}

{
  "sessionId": "cs_xxx" // Optional: only needed for first publish
}
```

**Success Response (200):**

```json
{
  "success": true,
  "siteUrl": "https://bobsoil.myamsoil.com",
  "message": "Site published successfully",
  "publishedAt": "2025-11-19T15:30:00Z"
}
```

**Error Response (500):**

```json
{
  "success": false,
  "error": "Failed to publish site",
  "code": "DB-002",
  "retryable": true
}
```

### Revalidation Endpoint

**Request:**

```http
POST /api/revalidate
Content-Type: application/json

{
  "path": "/dealers/bobsoil"
}
```

**Success Response (200):**

```json
{
  "revalidated": true,
  "path": "/dealers/bobsoil",
  "timestamp": "2025-11-19T15:30:00Z"
}
```

**Error Response (500):**

```json
{
  "error": "Failed to revalidate",
  "path": "/dealers/bobsoil"
}
```

---

## Document History

- **2025-11-19:** Initial design approved
- **Next:** Create implementation plan with specific tasks
