# Implementation Plan: Manual Policy Sync Button
**Branch**: `002-manual-policy-sync` | **Date**: 2025-12-06 | **Spec**: [spec.md](./spec.md)
**Status**: ✅ Implementation Complete (Documenting for record)
## Summary
Add a "Sync Policies" button to the global search page (`/search`) that allows admins to trigger immediate policy synchronization from Microsoft Intune via n8n webhook. This eliminates the need to wait for scheduled nightly syncs when policy changes need to be reflected immediately in TenantPilot.
**Technical Approach**: Implement as a client component with Server Action, following TenantPilot's server-first architecture. Use existing authentication system to extract tenant ID and send POST request to n8n webhook endpoint configured via environment variable.
## Technical Context
**Language/Version**: TypeScript 5.x strict mode
**Primary Dependencies**: Next.js 16.0.3 App Router, Drizzle ORM 0.44.7, Shadcn UI, NextAuth.js 4.24.13
**Storage**: PostgreSQL (no schema changes required)
**Testing**: Manual testing (no E2E tests for this feature)
**Target Platform**: Docker containers (standalone build), web browsers
**Project Type**: Web application (Next.js App Router)
**Performance Goals**: Webhook request < 2s, UI feedback < 1s
**Constraints**: Server-first architecture (Server Actions only), Azure AD multi-tenant auth
**Scale/Scope**: Multi-tenant SaaS, each tenant triggers sync independently
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- [x] Uses Next.js App Router with Server Actions (no client-side fetches)
- [x] TypeScript strict mode enabled (all types properly defined)
- [x] Drizzle ORM for all database operations (no DB changes needed)
- [x] Shadcn UI for all new components (Button from shadcn/ui)
- [x] Azure AD multi-tenant authentication (tenant ID from session)
- [x] Docker deployment with standalone build (no special config needed)
**Constitution Compliance**: ✅ All requirements met
## Project Structure
### Documentation (this feature)
```text
specs/002-manual-policy-sync/
├── spec.md # Feature specification (completed)
├── plan.md # This file (implementation plan)
├── checklists/
│ └── requirements.md # Specification quality checklist (passed)
└── [No research.md] # Not needed - straightforward implementation
└── [No data-model.md] # Not needed - no schema changes
└── [No contracts/] # Not needed - uses existing n8n webhook
└── [No quickstart.md] # Not needed - single feature, no complex setup
```
### Source Code (repository root)
```text
lib/
├── env.mjs # [MODIFIED] Added N8N_SYNC_WEBHOOK_URL
├── actions/
│ └── policySettings.ts # [MODIFIED] Added triggerPolicySync()
└── auth/
└── utils.ts # [MODIFIED] Added tenant_id DB update in JWT callback
components/
└── search/
├── SearchInput.tsx # [EXISTING] Search component
├── ResultsTable.tsx # [EXISTING] Results display
├── EmptyState.tsx # [EXISTING] No results state
└── SyncButton.tsx # [NEW] Sync button component
app/
└── (app)/
└── search/
└── page.tsx # [MODIFIED] Integrated SyncButton
specs/
└── 002-manual-policy-sync/ # [NEW] This feature spec directory
```
**Structure Decision**: Single Next.js project with App Router. Feature follows existing patterns from 001-global-policy-search. No new directories created - components added to existing `components/search/` structure.
## Phase 0: Research & Unknowns Resolution
**Status**: ✅ Complete (implementation straightforward, no research needed)
### Research Questions (All Resolved)
1. **Q**: How to trigger n8n workflow from Next.js?
**A**: POST request to webhook URL with JSON payload. n8n webhook accepts any POST with tenant ID.
2. **Q**: Where to store webhook URL?
**A**: Environment variable `N8N_SYNC_WEBHOOK_URL` in `lib/env.mjs`, validated with Zod (optional).
3. **Q**: How to get tenant ID?
**A**: From NextAuth session (`session.user.tenantId`) via `getUserAuth()` in Server Action.
4. **Q**: How to handle button loading state?
**A**: React `useTransition()` hook for pending state, disable button during transition.
5. **Q**: Toast notifications for feedback?
**A**: Existing `sonner` library already integrated, use `toast.success()` / `toast.error()`.
**No external research needed** - all patterns exist in codebase (Server Actions, auth, env vars, Shadcn UI).
## Phase 1: Design & Contracts
**Status**: ✅ Complete (no schema changes, webhook contract is simple)
### Data Model
**No database changes required**. Feature uses existing:
- `users` table (tenant_id column already added in previous feature)
- NextAuth session management (tenant ID already in JWT token)
### API Contracts
#### Server Action: `triggerPolicySync()`
**File**: `lib/actions/policySettings.ts`
```typescript
export async function triggerPolicySync(): Promise<{
success: boolean;
message?: string;
error?: string;
}>;
```
**Security**:
- ✅ Validates user session via `getUserAuth()`
- ✅ Extracts tenant ID from session (no user input)
- ✅ Server-side only (Server Action)
**Flow**:
1. Check authentication → return error if not authenticated
2. Extract `tenantId` from session → return error if missing
3. Validate `N8N_SYNC_WEBHOOK_URL` exists → return error if not configured
4. Send POST request to webhook
5. Return success/error based on response
#### n8n Webhook Contract (External)
**Endpoint**: `process.env.N8N_SYNC_WEBHOOK_URL` (configured in Dokploy)
**Method**: POST
**Headers**: `Content-Type: application/json`
**Body**:
```json
{
"tenantId": "00000000-0000-0000-0000-000000000000",
"source": "manual_trigger",
"triggeredAt": "2025-12-06T12:00:00.000Z"
}
```
**Response**:
- Success: HTTP 200-299 (body irrelevant)
- Failure: HTTP 400+ (triggers error toast)
### Component Design
#### SyncButton Component
**File**: `components/search/SyncButton.tsx`
**Props**: None (uses Server Action directly)
**State**:
- `isPending` (from `useTransition()`) - tracks request in progress
**UI States**:
1. **Idle**: "Sync Policies" with RefreshCw icon
2. **Loading**: "Syncing..." with spinning RefreshCw icon, button disabled
3. **Success**: Returns to idle, shows green toast
4. **Error**: Returns to idle, shows red toast
**Styling**: Shadcn Button component with default variant
### Page Integration
**File**: `app/(app)/search/page.tsx`
**Change**: Add `` in CardHeader, positioned right of title/description using flexbox.
```tsx
```
## Phase 2: Implementation Tasks
**Status**: ✅ All tasks completed
### Task Breakdown (Already Implemented)
#### T001: Environment Variable Setup
- [x] Add `N8N_SYNC_WEBHOOK_URL: z.string().optional()` to `lib/env.mjs` server schema
- [x] Document in `.env.example` (if exists) or README
#### T002: Server Action Implementation
- [x] Import `env` from `lib/env.mjs` in `lib/actions/policySettings.ts`
- [x] Create `triggerPolicySync()` async function with 'use server'
- [x] Add authentication check with `getUserAuth()`
- [x] Extract tenant ID from session with error handling
- [x] Validate webhook URL exists
- [x] Implement fetch POST request with try/catch
- [x] Return typed success/error response
#### T003: SyncButton Component
- [x] Create `components/search/SyncButton.tsx` as 'use client' component
- [x] Import Button from `@/components/ui/button`
- [x] Import RefreshCw icon from `lucide-react`
- [x] Import `triggerPolicySync` action
- [x] Import `toast` from `sonner`
- [x] Implement `useTransition()` for pending state
- [x] Create `handleSync` function calling Server Action
- [x] Add toast notifications for success/error
- [x] Render button with conditional icon spin animation
- [x] Disable button when `isPending` is true
#### T004: Page Integration
- [x] Import `SyncButton` in `app/(app)/search/page.tsx`
- [x] Modify CardHeader to use flexbox layout
- [x] Position button right of title/description
- [x] Verify responsive layout on mobile
#### T005: Testing & Validation
- [x] Test button click without webhook URL configured → error toast ✅
- [x] Test button click when not authenticated → error toast ✅
- [x] Test button click with valid webhook → success toast ✅
- [x] Test rapid button clicks → only one request sent ✅
- [x] Test network error simulation → error toast ✅
- [x] Verify tenant ID extracted correctly from session ✅
#### T006: Documentation
- [x] Update feature spec with actual implementation
- [x] Document N8N_SYNC_WEBHOOK_URL in environment variables
- [x] Add deployment notes for Dokploy configuration
- [x] Create this implementation plan
## Deployment Checklist
- [x] Code committed to `002-manual-policy-sync` branch
- [x] Feature spec and plan documented
- [ ] **TODO**: Set `N8N_SYNC_WEBHOOK_URL` in Dokploy environment variables
- [ ] **TODO**: Merge to `main` branch
- [ ] **TODO**: Deploy to production
- [ ] **TODO**: Verify button appears on `/search` page
- [ ] **TODO**: Test manual sync trigger in production
## Implementation Notes
### Key Decisions
1. **No rate limiting**: Decided against implementing rate limiting (e.g., 1 sync per 5 min) because:
- n8n can handle duplicate requests
- User feedback (loading state) discourages rapid clicks
- Can add later if abuse occurs
2. **No sync status tracking**: Feature only confirms sync was triggered, doesn't show progress because:
- Intune API sync is async and can take minutes
- Would require polling or WebSocket complexity
- Out of scope for MVP (marked in spec)
3. **Environment variable optional**: Made `N8N_SYNC_WEBHOOK_URL` optional (not required) because:
- Allows Docker build without secret
- Runtime check provides clear error message
- Follows pattern of other optional env vars
4. **Client component for button**: Had to use 'use client' despite server-first principle because:
- Need `useTransition()` for loading state
- Toast notifications run client-side
- Server Action called from client follows Next.js patterns
### Security Considerations
- ✅ Tenant ID extracted from authenticated session (not user input)
- ✅ Server Action validates auth before any operations
- ✅ Webhook URL not exposed to client (server-side env var)
- ✅ No CSRF risk (Server Action has built-in protection)
- ✅ No SQL injection risk (no database writes)
### Performance Considerations
- ✅ Webhook request non-blocking (doesn't wait for Intune sync)
- ✅ UI feedback immediate (< 1s to show loading state)
- ✅ No database queries (reads session from NextAuth)
- ✅ Button disabled prevents duplicate requests
- ✅ Minimal bundle size (lucide-react tree-shaken, sonner already loaded)
## Testing Strategy
### Manual Testing (Completed)
1. **Happy Path**:
- Login → Navigate to /search → Click "Sync Policies" → See "Syncing..." → See success toast
- Verify n8n receives webhook with correct tenant ID
2. **Error Cases**:
- Not authenticated → Error toast
- No tenant ID in session → Error toast
- Webhook URL not configured → Error toast
- Network error → Error toast
- Webhook returns 500 → Error toast
3. **UI/UX**:
- Button shows loading spinner
- Button disabled during sync
- Rapid clicks don't send multiple requests
- Mobile responsive layout
### Future Testing (Out of Scope for MVP)
- E2E test with Playwright
- Unit test for Server Action
- Integration test with mock n8n webhook
- Load test for concurrent sync requests
## Known Limitations
1. **No sync progress**: User only gets confirmation sync started, not when it completes
2. **No last sync timestamp**: UI doesn't show when data was last synced
3. **No rate limiting**: User could theoretically spam the button (mitigated by loading state)
4. **No tenant-specific sync**: Always syncs all policies, can't select specific ones
5. **No webhook retry**: If webhook fails, user must click again (no automatic retry)
## Next Steps (Post-MVP)
1. **Add sync history**: Track when syncs were triggered in database
2. **Show last sync time**: Display on search page "Last synced: 5 minutes ago"
3. **Add rate limiting**: Prevent syncs more frequently than once per 5 minutes
4. **Add sync status polling**: Show progress while Intune data is being fetched
5. **Add sync notifications**: Email/toast when sync completes (async)
## References
- Feature Spec: [spec.md](./spec.md)
- Requirements Checklist: [checklists/requirements.md](./checklists/requirements.md)
- Similar Feature: [specs/001-global-policy-search/](../001-global-policy-search/)
- Constitution: [.specify/memory/constitution.md](../../.specify/memory/constitution.md)