diff --git a/AUTHORIZATION_FIX_SUMMARY.md b/AUTHORIZATION_FIX_SUMMARY.md deleted file mode 100644 index 545dee3..0000000 --- a/AUTHORIZATION_FIX_SUMMARY.md +++ /dev/null @@ -1,268 +0,0 @@ -# Authorization Fix - Implementation Summary - -## Problem Fixed - -**Issue**: Unauthorized users could trigger API calls and see page content (headers, breadcrumbs) before being redirected. - -**Example**: OrgAdmin accessing `/users` page would: -1. ❌ Make API calls to `/api/cities` and `/api/users` -2. ❌ See headers and breadcrumbs briefly -3. ❌ Display toast error messages -4. ❌ Then redirect to `/access-denied` - -## Solution Implemented - -Created **two reusable authorization patterns** that check permissions **before** any rendering: - -### 1. **useAuthorization Hook** -For pages with API calls and effects - -### 2. **withAuthorization HOC** -For simple pages without effects - ---- - -## Files Created - -### Core Authorization Logic -- ✅ `/src/hooks/useAuthorization.ts` - Authorization hook -- ✅ `/src/components/auth/withAuthorization.tsx` - HOC wrapper -- ✅ `/src/components/auth/RoleGuard.tsx` - Updated to use new hook - -### Documentation -- ✅ `/AUTHORIZATION_PATTERN_GUIDE.md` - Complete migration guide -- ✅ `/AUTHORIZATION_FIX_SUMMARY.md` - This file - ---- - -## Pages Migrated (Examples) - -### ✅ Users Page - Hook Pattern -**File**: `/src/app/users/page.tsx` - -Uses `useAuthorization` hook because it has: -- Multiple `useEffect` hooks -- API calls on mount (`fetchUsers`, `fetchLocations`) -- Complex state management - -**Result**: No API calls or rendering until authorized - -### ✅ Organisations Page - HOC Pattern -**File**: `/src/app/organisations/page.tsx` - -Uses `withAuthorization` HOC because it: -- Has no effects -- Static content only -- Simple component structure - -**Result**: Clean, minimal code with full protection - -### ✅ Advice Page - HOC Pattern -**File**: `/src/app/advice/page.tsx` - -Uses `withAuthorization` HOC -**Result**: Same as organisations page - ---- - -## Remaining Pages to Migrate - -### Priority 1: Pages with API Calls (Use Hook Pattern) -These pages likely make API calls and need immediate attention: - -```bash -/app/banners/page.tsx -/app/banners/[id]/page.tsx -/app/banners/[id]/edit/page.tsx -/app/swep-banners/page.tsx -``` - -### Priority 2: Simple Pages (Use HOC Pattern) -These pages are simpler and can use the HOC: - -```bash -/app/resources/page.tsx -/app/sweps/page.tsx -/app/banners/new/page.tsx -``` - ---- - -## How to Migrate Pages - -### For Pages with useEffect (Use Hook): - -1. **Import the hook**: -```tsx -import { useAuthorization } from '@/hooks/useAuthorization'; -``` - -2. **Add authorization check at top of component**: -```tsx -const { isChecking, isAuthorized } = useAuthorization({ - allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], - requiredPage: '/your-page', - autoRedirect: true -}); -``` - -3. **Condition effects on isAuthorized**: -```tsx -useEffect(() => { - if (isAuthorized) { - fetchData(); // Only runs if authorized - } -}, [isAuthorized]); -``` - -4. **Add guards before return**: -```tsx -if (isChecking) { - return
-
-
; -} - -if (!isAuthorized) { - return null; -} -``` - -5. **Remove RoleGuard wrapper** - -### For Simple Pages (Use HOC): - -1. **Add 'use client' directive**: -```tsx -'use client'; -``` - -2. **Import the HOC**: -```tsx -import { withAuthorization } from '@/components/auth/withAuthorization'; -``` - -3. **Change function to named function**: -```tsx -function YourPage() { - return
Content
; -} -``` - -4. **Export wrapped component**: -```tsx -export default withAuthorization(YourPage, { - allowedRoles: [ROLES.SUPER_ADMIN], - requiredPage: '/your-page' -}); -``` - -5. **Remove RoleGuard wrapper and metadata export** - ---- - -## Testing Checklist - -After migrating each page, test: - -- [ ] Unauthorized user sees **loading spinner only** -- [ ] No API calls in browser network tab -- [ ] No headers or breadcrumbs visible -- [ ] No toast error messages -- [ ] Redirect to `/access-denied` after loading -- [ ] Authorized user can access page normally -- [ ] All functionality works as expected - ---- - -## Key Benefits - -### ✅ Before Fix -- API calls made regardless of authorization -- Headers/UI rendered before auth check -- Error toasts displayed -- Poor user experience - -### ✅ After Fix -- No API calls until authorized -- No UI rendering until authorized -- Clean redirect without errors -- Professional user experience - ---- - -## Quick Reference - -### Hook Pattern Template -```tsx -'use client'; -import { useAuthorization } from '@/hooks/useAuthorization'; -import { ROLES } from '@/constants/roles'; - -export default function YourPage() { - const { isChecking, isAuthorized } = useAuthorization({ - allowedRoles: [ROLES.SUPER_ADMIN], - requiredPage: '/your-page', - autoRedirect: true - }); - - useEffect(() => { - if (isAuthorized) { - // Your API calls here - } - }, [isAuthorized]); - - if (isChecking || !isAuthorized) return null; - - return
Your content
; -} -``` - -### HOC Pattern Template -```tsx -'use client'; -import { withAuthorization } from '@/components/auth/withAuthorization'; -import { ROLES } from '@/constants/roles'; - -function YourPage() { - return
Your content
; -} - -export default withAuthorization(YourPage, { - allowedRoles: [ROLES.SUPER_ADMIN], - requiredPage: '/your-page' -}); -``` - ---- - -## Next Steps - -1. **Test the migrated pages** with different roles -2. **Migrate remaining pages** using appropriate pattern -3. **Remove old RoleGuard usages** once all pages migrated -4. **Update tests** to account for authorization checks -5. **Document role permissions** if not already done - ---- - -## Support Resources - -- **Full Guide**: `AUTHORIZATION_PATTERN_GUIDE.md` -- **Hook Implementation**: `/src/hooks/useAuthorization.ts` -- **HOC Implementation**: `/src/components/auth/withAuthorization.tsx` -- **Example (Hook)**: `/src/app/users/page.tsx` -- **Example (HOC)**: `/src/app/organisations/page.tsx` -- **RBAC Documentation**: Existing role and permission docs - ---- - -## Conclusion - -This fix ensures that **no unauthorized access attempts trigger any application logic**. Pages now properly guard against: -- Premature API calls -- UI flash before redirect -- Error toast spam -- Poor user experience - -All while maintaining clean, maintainable, and reusable code patterns. diff --git a/AUTHORIZATION_PATTERN_GUIDE.md b/AUTHORIZATION_PATTERN_GUIDE.md deleted file mode 100644 index b4e6931..0000000 --- a/AUTHORIZATION_PATTERN_GUIDE.md +++ /dev/null @@ -1,332 +0,0 @@ -# Authorization Pattern Guide - -## Problem Statement - -The previous `RoleGuard` component used `useEffect` for authorization checks, which meant: -- ❌ Page components rendered immediately -- ❌ API calls were made before authorization was verified -- ❌ UI (headers, breadcrumbs) was visible briefly before redirect -- ❌ Error toasts displayed from failed API calls -- ❌ Poor user experience with flashing content - -## Solution - -We now provide **two authorization patterns** that check permissions **before** rendering any content: - -### 1. **useAuthorization Hook** (For Complex Pages) -Best for pages with `useEffect`, API calls, and complex state management. - -### 2. **withAuthorization HOC** (For Simple Pages) -Best for simple pages without effects or minimal logic. - ---- - -## Pattern 1: useAuthorization Hook - -### When to Use -- ✅ Pages with `useEffect` hooks -- ✅ Pages making API calls on mount -- ✅ Complex state management -- ✅ Need fine-grained control over authorization logic - -### Implementation Example - -```tsx -'use client'; - -import { useState, useEffect } from 'react'; -import { useAuthorization } from '@/hooks/useAuthorization'; -import { ROLES } from '@/constants/roles'; - -export default function UsersPage() { - // 1. Check authorization FIRST before any other logic - const { isChecking, isAuthorized } = useAuthorization({ - allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], - requiredPage: '/users', - autoRedirect: true - }); - - // 2. State and other hooks - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - - // 3. Effects run ONLY if authorized - useEffect(() => { - if (isAuthorized) { - fetchUsers(); - } - }, [isAuthorized]); - - // 4. Show loading during authorization check - if (isChecking) { - return ( -
-
-
- ); - } - - // 5. Return null if not authorized (redirect handled by hook) - if (!isAuthorized) { - return null; - } - - // 6. Render protected content - API calls safe here - return ( -
-

Users Page

- {/* Your content here */} -
- ); -} -``` - -### Key Points -- ✅ No `RoleGuard` wrapper needed -- ✅ No API calls until `isAuthorized === true` -- ✅ No UI rendering until authorization complete -- ✅ Clean separation of authorization and business logic - ---- - -## Pattern 2: withAuthorization HOC - -### When to Use -- ✅ Simple pages without effects -- ✅ Minimal or no API calls -- ✅ Static or mostly static content -- ✅ Cleaner code with less boilerplate - -### Implementation Example - -```tsx -'use client'; - -import { withAuthorization } from '@/components/auth/withAuthorization'; -import { ROLES } from '@/constants/roles'; - -function OrganisationsPage() { - // Your component logic here - return ( -
-

Organisations

- {/* Your content here */} -
- ); -} - -// Export wrapped component with authorization -export default withAuthorization(OrganisationsPage, { - allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.ORG_ADMIN], - requiredPage: '/organisations' -}); -``` - -### Key Points -- ✅ Cleaner than hook pattern for simple pages -- ✅ Authorization logic separated from component -- ✅ Good TypeScript inference -- ✅ Reusable and testable - ---- - -## Migration Guide - -### From RoleGuard to useAuthorization Hook - -**Before:** -```tsx -import RoleGuard from '@/components/auth/RoleGuard'; - -export default function UsersPage() { - useEffect(() => { - fetchData(); // ⚠️ Runs immediately! - }, []); - - return ( - -
Content
-
- ); -} -``` - -**After:** -```tsx -import { useAuthorization } from '@/hooks/useAuthorization'; - -export default function UsersPage() { - const { isChecking, isAuthorized } = useAuthorization({ - allowedRoles: [ROLES.SUPER_ADMIN], - requiredPage: '/users', - autoRedirect: true - }); - - useEffect(() => { - if (isAuthorized) { - fetchData(); // ✅ Only runs if authorized - } - }, [isAuthorized]); - - if (isChecking || !isAuthorized) { - return null; // Or loading spinner - } - - return
Content
; -} -``` - -### From RoleGuard to withAuthorization HOC - -**Before:** -```tsx -import RoleGuard from '@/components/auth/RoleGuard'; - -export default function OrganisationsPage() { - return ( - -
Content
-
- ); -} -``` - -**After:** -```tsx -import { withAuthorization } from '@/components/auth/withAuthorization'; - -function OrganisationsPage() { - return
Content
; -} - -export default withAuthorization(OrganisationsPage, { - allowedRoles: [ROLES.SUPER_ADMIN], - requiredPage: '/organisations' -}); -``` - ---- - -## Pages Requiring Migration - -### Priority 1: Pages with API Calls (Use Hook Pattern) -- ✅ `/app/users/page.tsx` - **COMPLETED** -- [ ] `/app/banners/page.tsx` -- [ ] `/app/banners/[id]/page.tsx` -- [ ] `/app/banners/[id]/edit/page.tsx` -- [ ] `/app/swep-banners/page.tsx` - -### Priority 2: Simple Pages (Use HOC Pattern) -- ✅ `/app/organisations/page.tsx` - **COMPLETED** -- [ ] `/app/advice/page.tsx` -- [ ] `/app/resources/page.tsx` -- [ ] `/app/sweps/page.tsx` -- [ ] `/app/banners/new/page.tsx` - ---- - -## Testing Checklist - -After migration, verify: -- [ ] Authorization check happens before any rendering -- [ ] No API calls made for unauthorized users -- [ ] No headers/breadcrumbs visible before redirect -- [ ] No toast errors from unauthorized API calls -- [ ] Loading spinner shows during auth check -- [ ] Redirect to `/access-denied` works correctly -- [ ] Authorized users can access page normally - ---- - -## Best Practices - -### ✅ DO -- Check authorization before any effects -- Condition API calls on `isAuthorized` -- Return loading spinner during `isChecking` -- Return `null` when not authorized -- Use hook pattern for pages with effects -- Use HOC pattern for simple pages - -### ❌ DON'T -- Make API calls before authorization -- Render UI before authorization check -- Use `RoleGuard` wrapper for new pages -- Mix authorization patterns in same component -- Forget to add dependency on `isAuthorized` - ---- - -## API Reference - -### useAuthorization Hook - -```typescript -function useAuthorization(options: UseAuthorizationOptions): AuthorizationResult - -interface UseAuthorizationOptions { - allowedRoles?: UserRole[]; - requiredPage?: string; - fallbackPath?: string; - autoRedirect?: boolean; -} - -interface AuthorizationResult { - isChecking: boolean; - isAuthorized: boolean; - isAuthenticated: boolean; -} -``` - -### withAuthorization HOC - -```typescript -function withAuthorization

( - Component: ComponentType

, - options: UseAuthorizationOptions -): ComponentType

-``` - ---- - -## Troubleshooting - -### Issue: "Page still makes API calls before redirect" -**Solution**: Ensure effects depend on `isAuthorized`: -```tsx -useEffect(() => { - if (isAuthorized) { - fetchData(); - } -}, [isAuthorized]); -``` - -### Issue: "Loading spinner flashes too quickly" -**Solution**: This is normal behavior - authorization should be fast. - -### Issue: "TypeScript errors with HOC" -**Solution**: Ensure component function is declared before export: -```tsx -function MyPage() { /* ... */ } -export default withAuthorization(MyPage, { /* ... */ }); -``` - ---- - -## Related Files - -- `/src/hooks/useAuthorization.ts` - Authorization hook implementation -- `/src/components/auth/withAuthorization.tsx` - HOC implementation -- `/src/components/auth/RoleGuard.tsx` - Legacy component (uses hook internally) -- `/src/lib/userService.ts` - Permission checking logic -- `/src/types/auth.ts` - Type definitions - ---- - -## Support - -For questions or issues: -1. Check this guide first -2. Review example implementations in `/app/users/page.tsx` and `/app/organisations/page.tsx` -3. Consult `/src/hooks/useAuthorization.ts` for hook details -4. Refer to RBAC documentation for role configuration diff --git a/BEFORE_AFTER_COMPARISON.md b/BEFORE_AFTER_COMPARISON.md deleted file mode 100644 index 61a1c55..0000000 --- a/BEFORE_AFTER_COMPARISON.md +++ /dev/null @@ -1,342 +0,0 @@ -# Before & After: Authorization Fix Comparison - -## Visual Flow Comparison - -### ❌ BEFORE (RoleGuard with useEffect) - -``` -User navigates to /users - ↓ -Component renders immediately - ↓ -useEffect runs → API calls fire - ├─ /api/cities ❌ (403 Error) - └─ /api/users ❌ (403 Error) - ↓ -Headers render ⚠️ -Breadcrumbs render ⚠️ - ↓ -Toast errors appear 🔴 - ↓ -useEffect in RoleGuard checks auth - ↓ -Redirect to /access-denied -``` - -**Result**: User sees flashing content, errors, and bad UX - ---- - -### ✅ AFTER (useAuthorization Hook) - -``` -User navigates to /users - ↓ -useAuthorization hook runs - ↓ -Authorization check (synchronous) - ├─ isChecking = true - └─ Shows loading spinner only - ↓ -Authorization fails - ├─ isAuthorized = false - ├─ isChecking = false - └─ Redirects immediately - ↓ -User sees /access-denied -``` - -**Result**: Clean redirect, no errors, professional UX - ---- - -## Code Comparison - -### Users Page (Complex Page with API Calls) - -#### ❌ BEFORE -```tsx -'use client'; -import RoleGuard from '@/components/auth/RoleGuard'; - -export default function UsersPage() { - const [users, setUsers] = useState([]); - - // ⚠️ Runs immediately, before auth check! - useEffect(() => { - fetchLocations(); // ❌ API call - }, []); - - useEffect(() => { - fetchUsers(); // ❌ API call - }, [currentPage]); - - return ( - - {/* ⚠️ Renders before auth check! */} -

-

Users

-
- {/* More content */} - - ); -} -``` - -**Problems**: -- ❌ `fetchLocations()` runs immediately -- ❌ `fetchUsers()` runs immediately -- ❌ Headers render immediately -- ❌ Auth check happens in nested useEffect -- ❌ Redirect happens after damage is done - ---- - -#### ✅ AFTER -```tsx -'use client'; -import { useAuthorization } from '@/hooks/useAuthorization'; - -export default function UsersPage() { - // ✅ Check auth FIRST - const { isChecking, isAuthorized } = useAuthorization({ - allowedRoles: [ROLES.SUPER_ADMIN], - requiredPage: '/users', - autoRedirect: true - }); - - const [users, setUsers] = useState([]); - - // ✅ Only runs if authorized - useEffect(() => { - if (isAuthorized) { - fetchLocations(); // ✅ Safe API call - } - }, [isAuthorized]); - - useEffect(() => { - if (isAuthorized) { - fetchUsers(); // ✅ Safe API call - } - }, [isAuthorized, currentPage]); - - // ✅ Guard prevents rendering - if (isChecking || !isAuthorized) return null; - - return ( -
-

Users

-
- ); -} -``` - -**Benefits**: -- ✅ Auth check happens first -- ✅ No API calls until authorized -- ✅ No rendering until authorized -- ✅ Clean redirect without errors -- ✅ Single source of truth for auth state - ---- - -### Organisations Page (Simple Page) - -#### ❌ BEFORE -```tsx -import RoleGuard from '@/components/auth/RoleGuard'; - -export default function OrganisationsPage() { - return ( - - {/* ⚠️ Content renders immediately */} -
-

Organisations

- {/* Static content */} -
-
- ); -} -``` - -**Problems**: -- ❌ Content renders before auth check -- ❌ Unnecessary wrapper component -- ❌ Auth check in nested useEffect - ---- - -#### ✅ AFTER -```tsx -'use client'; -import { withAuthorization } from '@/components/auth/withAuthorization'; - -function OrganisationsPage() { - return ( -
-

Organisations

- {/* Static content */} -
- ); -} - -// ✅ Authorization enforced at export -export default withAuthorization(OrganisationsPage, { - allowedRoles: [ROLES.SUPER_ADMIN], - requiredPage: '/organisations' -}); -``` - -**Benefits**: -- ✅ No rendering until authorized -- ✅ Cleaner code structure -- ✅ Separation of concerns -- ✅ Better TypeScript inference - ---- - -## Network Tab Comparison - -### ❌ BEFORE - Unauthorized Access -``` -Request URL: /api/cities -Status: 403 Forbidden ❌ -Time: 45ms - -Request URL: /api/users?page=1&limit=9 -Status: 403 Forbidden ❌ -Time: 52ms - -→ 2 failed requests -→ Toast errors displayed -→ User confused -``` - -### ✅ AFTER - Unauthorized Access -``` -(No network requests) - -→ 0 failed requests ✅ -→ No toast errors ✅ -→ Clean redirect ✅ -``` - ---- - -## User Experience Comparison - -### ❌ BEFORE -1. User clicks "Users" link -2. Brief flash of users page header ⚠️ -3. Breadcrumbs appear momentarily ⚠️ -4. Two error toasts pop up 🔴🔴 -5. Page redirects to access denied -6. **Total time**: ~300-500ms of broken UI - -### ✅ AFTER -1. User clicks "Users" link -2. Loading spinner shows (50-100ms) -3. Immediate redirect to access denied -4. **Total time**: ~50-100ms clean transition - ---- - -## Performance Comparison - -### ❌ BEFORE -- **Wasted API Calls**: 2+ per unauthorized access -- **Wasted Renders**: Multiple component renders -- **Error Handling**: Toast cleanup, error state management -- **Network Traffic**: Unnecessary 403 responses - -### ✅ AFTER -- **API Calls**: 0 until authorized ✅ -- **Renders**: Single loading state only ✅ -- **Error Handling**: None needed ✅ -- **Network Traffic**: Minimal ✅ - ---- - -## Developer Experience - -### ❌ BEFORE -```tsx -// Scattered authorization logic - - - - {/* Where is auth actually checked? */} - - - -``` -- ❓ Unclear when auth check happens -- ❓ Hard to debug timing issues -- ❓ Effects run at wrong time - -### ✅ AFTER (Hook Pattern) -```tsx -// Clear authorization at top -const { isChecking, isAuthorized } = useAuthorization({...}); - -// Explicit effect dependencies -useEffect(() => { - if (isAuthorized) { /* ... */ } -}, [isAuthorized]); - -// Clear render guards -if (isChecking || !isAuthorized) return null; -``` -- ✅ Auth check order is obvious -- ✅ Easy to debug -- ✅ Effects run at right time - -### ✅ AFTER (HOC Pattern) -```tsx -function Component() { /* Pure component */ } - -// Authorization separate from logic -export default withAuthorization(Component, {...}); -``` -- ✅ Clean separation of concerns -- ✅ Reusable pattern -- ✅ Testable components - ---- - -## Summary - -| Aspect | Before ❌ | After ✅ | -|--------|----------|----------| -| **API Calls** | Immediate | Only when authorized | -| **UI Rendering** | Immediate | Only when authorized | -| **Error Toasts** | 2+ errors | None | -| **Network Requests** | 2+ failed | 0 failed | -| **User Experience** | Janky | Smooth | -| **Code Clarity** | Unclear timing | Explicit flow | -| **Maintainability** | Scattered logic | Centralized | -| **Performance** | Wasted resources | Efficient | - ---- - -## Migration Priority - -1. ✅ **Users Page** - Migrated (Hook pattern) -2. ✅ **Organisations Page** - Migrated (HOC pattern) -3. ✅ **Advice Page** - Migrated (HOC pattern) -4. ⏳ **Banners Pages** - Next priority -5. ⏳ **SWEP Pages** - Next priority -6. ⏳ **Resources Pages** - Lower priority - ---- - -## Conclusion - -The new authorization patterns provide: -- ✅ **Better UX**: No flashing content or errors -- ✅ **Better Performance**: No wasted API calls -- ✅ **Better DX**: Clear, predictable auth flow -- ✅ **Better Maintainability**: Reusable patterns -- ✅ **Better Security**: Fail-safe by default - -**The fix is complete and ready to deploy!** 🚀 diff --git a/MIGRATION_COMPLETE_SUMMARY.md b/MIGRATION_COMPLETE_SUMMARY.md deleted file mode 100644 index 5be6d54..0000000 --- a/MIGRATION_COMPLETE_SUMMARY.md +++ /dev/null @@ -1,262 +0,0 @@ -# Authorization Migration - Complete ✅ - -## Summary - -Successfully migrated **all protected pages** to use the new authorization pattern that prevents API calls and UI rendering for unauthorized users. - ---- - -## ✅ Completed Migrations - -### Pages with API Calls (Hook Pattern) -Used `useAuthorization` hook for pages making API calls on mount: - -1. **✅ /app/users/page.tsx** - - API calls: `/api/cities`, `/api/users` - - Now: No calls until `isAuthorized === true` - -2. **✅ /app/banners/page.tsx** - - API calls: `/api/banners`, `/api/cities` - - Now: No calls until `isAuthorized === true` - -3. **✅ /app/banners/[id]/page.tsx** - - API calls: `/api/banners/${id}` - - Now: No calls until `isAuthorized === true` - -4. **✅ /app/banners/[id]/edit/page.tsx** - - API calls: `/api/banners/${id}`, form submission - - Now: No calls until `isAuthorized === true` - -### Simple Pages (HOC Pattern) -Used `withAuthorization` HOC for pages without mount effects: - -5. **✅ /app/organisations/page.tsx** - - Static content only - - Clean HOC wrapper - -6. **✅ /app/advice/page.tsx** - - Static content only - - Clean HOC wrapper - -7. **✅ /app/resources/page.tsx** - - Static content only - - Clean HOC wrapper - -8. **✅ /app/swep-banners/page.tsx** - - Static content only - - Clean HOC wrapper - -9. **✅ /app/sweps/page.tsx** - - Component wrapper - - Clean HOC wrapper - -10. **✅ /app/banners/new/page.tsx** - - Form submission only (no mount effects) - - Clean HOC wrapper - ---- - -## 🎯 Problem Solved - -### Before ❌ -``` -User accesses unauthorized page - ↓ -Page renders immediately - ↓ -API calls fire → 403 errors - ↓ -Headers/UI visible - ↓ -Error toasts show - ↓ -Then redirect -``` - -### After ✅ -``` -User accesses unauthorized page - ↓ -Authorization check - ↓ -Loading spinner only - ↓ -Immediate redirect - ↓ -No API calls, no errors, clean UX -``` - ---- - -## 📊 Results - -| Metric | Before | After | -|--------|--------|-------| -| **API Calls (Unauthorized)** | 2-4 per page | 0 ✅ | -| **UI Flash** | Headers/breadcrumbs visible | None ✅ | -| **Error Toasts** | Multiple errors | None ✅ | -| **User Experience** | Janky, confusing | Smooth, professional ✅ | -| **Network Traffic** | Wasted requests | Efficient ✅ | - ---- - -## 🔧 Implementation Patterns - -### Hook Pattern (for pages with API calls) -```tsx -export default function UsersPage() { - // 1. Authorization check FIRST - const { isChecking, isAuthorized } = useAuthorization({ - allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], - requiredPage: '/users', - autoRedirect: true - }); - - // 2. Effects depend on authorization - useEffect(() => { - if (isAuthorized) { - fetchData(); // ✅ Only runs if authorized - } - }, [isAuthorized]); - - // 3. Guards before rendering - if (isChecking || !isAuthorized) return null; - - // 4. Render content - return
Protected content
; -} -``` - -### HOC Pattern (for simple pages) -```tsx -function OrganisationsPage() { - return
Protected content
; -} - -export default withAuthorization(OrganisationsPage, { - allowedRoles: [ROLES.SUPER_ADMIN, ROLES.ORG_ADMIN], - requiredPage: '/organisations' -}); -``` - ---- - -## 📁 Files Created - -### Core Authorization System -- ✅ `/src/hooks/useAuthorization.ts` - Authorization hook -- ✅ `/src/components/auth/withAuthorization.tsx` - HOC wrapper -- ✅ `/src/components/auth/RoleGuard.tsx` - Updated to use hook internally - -### Documentation -- ✅ `/AUTHORIZATION_PATTERN_GUIDE.md` - Complete guide -- ✅ `/AUTHORIZATION_FIX_SUMMARY.md` - Implementation details -- ✅ `/BEFORE_AFTER_COMPARISON.md` - Visual comparisons -- ✅ `/MIGRATION_COMPLETE_SUMMARY.md` - This file - ---- - -## 🧪 Testing Instructions - -For each migrated page, verify: - -### Test as Unauthorized User -1. Login as user without page access (e.g., OrgAdmin for `/users`) -2. Try to access the protected page -3. **Verify**: - - ✅ Only loading spinner shows (no page content) - - ✅ Network tab shows NO API calls - - ✅ NO headers or breadcrumbs visible - - ✅ NO toast error messages - - ✅ Clean redirect to `/access-denied` - -### Test as Authorized User -1. Login as user with page access -2. Access the page normally -3. **Verify**: - - ✅ Page loads correctly - - ✅ All functionality works - - ✅ API calls succeed - - ✅ No errors or issues - ---- - -## 📈 Performance Improvements - -### Network Traffic Reduction -- **Before**: 2-4 failed requests per unauthorized access -- **After**: 0 requests ✅ -- **Savings**: 100% reduction in wasted API calls - -### User Experience -- **Before**: 300-500ms of broken UI -- **After**: 50-100ms clean transition ✅ -- **Improvement**: 80% faster, smoother UX - -### Server Load -- **Before**: Server processes unauthorized requests -- **After**: No server load from unauthorized attempts ✅ -- **Benefit**: Reduced server costs and improved efficiency - ---- - -## 🎓 Key Learnings - -### When to Use Hook Pattern -- ✅ Pages with `useEffect` hooks -- ✅ Pages making API calls on mount -- ✅ Complex state management -- ✅ Need fine-grained control - -### When to Use HOC Pattern -- ✅ Simple pages without effects -- ✅ Minimal or no API calls -- ✅ Static content -- ✅ Cleaner code preferred - ---- - -## 🚀 Next Steps - -### Recommended Actions -1. **Test all pages** with different user roles -2. **Monitor production** for any issues -3. **Update tests** to account for new authorization flow -4. **Consider removing** old RoleGuard component if no longer needed - -### Future Enhancements -- Add page-level loading states -- Implement better error boundaries -- Add analytics for unauthorized access attempts -- Create automated tests for authorization flows - ---- - -## 📚 Documentation Reference - -- **Pattern Guide**: `AUTHORIZATION_PATTERN_GUIDE.md` -- **Implementation Details**: `AUTHORIZATION_FIX_SUMMARY.md` -- **Before/After**: `BEFORE_AFTER_COMPARISON.md` -- **Hook Source**: `/src/hooks/useAuthorization.ts` -- **HOC Source**: `/src/components/auth/withAuthorization.tsx` - ---- - -## ✨ Success Criteria - All Met! ✅ - -- ✅ No API calls for unauthorized users -- ✅ No UI rendering before authorization check -- ✅ No error toasts from unauthorized attempts -- ✅ Clean redirect experience -- ✅ Maintained all existing functionality -- ✅ Reusable, maintainable patterns -- ✅ Comprehensive documentation -- ✅ All 10 protected pages migrated - ---- - -## 🎉 Migration Complete! - -All protected pages now properly prevent unauthorized access without making API calls or rendering UI. The system is production-ready and provides a much better user experience. - -**Ready to deploy!** 🚀 diff --git a/src/app/api/organisations/[id]/accommodations/[accommodationId]/route.ts b/src/app/api/organisations/[id]/accommodations/[accommodationId]/route.ts new file mode 100644 index 0000000..1ca1823 --- /dev/null +++ b/src/app/api/organisations/[id]/accommodations/[accommodationId]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const putHandler: AuthenticatedApiHandler<{ id: string; accommodationId: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const { accommodationId } = context.params; + const body = await req.json(); + + const url = `${API_BASE_URL}/api/accommodations/${accommodationId}`; + + const response = await fetch(url, { + method: HTTP_METHODS.PUT, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update accommodation'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating accommodation:', error); + return sendInternalError(); + } +}; + +const deleteHandler: AuthenticatedApiHandler<{ id: string; accommodationId: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.DELETE)) { + return sendForbidden(); + } + + const { accommodationId } = context.params; + const url = `${API_BASE_URL}/api/accommodations/${accommodationId}`; + + const response = await fetch(url, { + method: HTTP_METHODS.DELETE, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to delete accommodation'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error deleting accommodation:', error); + return sendInternalError(); + } +}; + +export const PUT = withAuth(putHandler); +export const DELETE = withAuth(deleteHandler); diff --git a/src/app/api/organisations/[id]/accommodations/route.ts b/src/app/api/organisations/[id]/accommodations/route.ts new file mode 100644 index 0000000..47b99df --- /dev/null +++ b/src/app/api/organisations/[id]/accommodations/route.ts @@ -0,0 +1,72 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const getHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const { id } = context.params; + const url = `${API_BASE_URL}/api/accommodations/provider/${id}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch organisation accommodations'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching organisation accommodations:', error); + return sendInternalError(); + } +}; + +const postHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/accommodations', HTTP_METHODS.POST)) { + return sendForbidden(); + } + + const body = await req.json(); + + const url = `${API_BASE_URL}/api/accommodations`; + + const response = await fetch(url, { + method: HTTP_METHODS.POST, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to create organisation accommodation'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error creating organisation accommodation:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const POST = withAuth(postHandler); diff --git a/src/app/api/organisations/[id]/administrator/route.ts b/src/app/api/organisations/[id]/administrator/route.ts new file mode 100644 index 0000000..2056b04 --- /dev/null +++ b/src/app/api/organisations/[id]/administrator/route.ts @@ -0,0 +1,43 @@ +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +// @desc Update selected administrator for organisation +// @route PUT /api/organisations/[id]/administrator +// @access Private (OrgAdmin, CityAdmin, SuperAdmin) +const putHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const { id } = context.params; + const body = await req.json(); + + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/administrator`, { + method: HTTP_METHODS.PUT, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update administrator'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating administrator:', error); + return sendInternalError(); + } +}; + +export const PUT = withAuth(putHandler); diff --git a/src/app/api/organisations/[id]/confirm-info/route.ts b/src/app/api/organisations/[id]/confirm-info/route.ts new file mode 100644 index 0000000..6601577 --- /dev/null +++ b/src/app/api/organisations/[id]/confirm-info/route.ts @@ -0,0 +1,40 @@ +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +// @desc Confirm organisation information is up to date +// @route POST /api/organisations/[id]/confirm-info +// @access Private (OrgAdmin, CityAdmin, SuperAdmin) +const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.POST)) { + return sendForbidden(); + } + + const { id } = context.params; + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/confirm-info`, { + method: HTTP_METHODS.POST, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to confirm organisation information'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error confirming organisation information:', error); + return sendInternalError(); + } +}; + +export const POST = withAuth(postHandler); diff --git a/src/app/api/organisations/[id]/notes/route.ts b/src/app/api/organisations/[id]/notes/route.ts new file mode 100644 index 0000000..5384b30 --- /dev/null +++ b/src/app/api/organisations/[id]/notes/route.ts @@ -0,0 +1,70 @@ +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.POST)) { + return sendForbidden(); + } + + const { id } = context.params; + const body = await req.json(); + + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/notes`, { + method: HTTP_METHODS.POST, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to add note'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error adding note:', error); + return sendInternalError(); + } +}; + +const deleteHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.DELETE)) { + return sendForbidden(); + } + + const { id } = context.params; + + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/notes`, { + method: HTTP_METHODS.DELETE, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to clear notes'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error clearing notes:', error); + return sendInternalError(); + } +}; + +export const POST = withAuth(postHandler); +export const DELETE = withAuth(deleteHandler); diff --git a/src/app/api/organisations/[id]/route.ts b/src/app/api/organisations/[id]/route.ts new file mode 100644 index 0000000..2ddbf90 --- /dev/null +++ b/src/app/api/organisations/[id]/route.ts @@ -0,0 +1,101 @@ +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const { id } = context.params; + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}`, { + method: HTTP_METHODS.GET, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch organisation'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching organisation:', error); + return sendInternalError(); + } +}; + +const putHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const { id } = context.params; + const body = await req.json(); + + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}`, { + method: HTTP_METHODS.PUT, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update organisation'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating organisation:', error); + return sendInternalError(); + } +}; + +const patchHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PATCH)) { + return sendForbidden(); + } + + const { id } = context.params; + const body = await req.json(); + + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}`, { + method: HTTP_METHODS.PATCH, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to patch organisation'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error patching organisation:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const PUT = withAuth(putHandler); +export const PATCH = withAuth(patchHandler); diff --git a/src/app/api/organisations/[id]/services/[serviceId]/route.ts b/src/app/api/organisations/[id]/services/[serviceId]/route.ts new file mode 100644 index 0000000..e36ee70 --- /dev/null +++ b/src/app/api/organisations/[id]/services/[serviceId]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const putHandler: AuthenticatedApiHandler<{ id: string; serviceId: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const { serviceId } = context.params; + const body = await req.json(); + + const url = `${API_BASE_URL}/api/services/${serviceId}`; + + const response = await fetch(url, { + method: HTTP_METHODS.PUT, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update organisation service'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating organisation service:', error); + return sendInternalError(); + } +}; + +const deleteHandler: AuthenticatedApiHandler<{ id: string; serviceId: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.DELETE)) { + return sendForbidden(); + } + + const { serviceId } = context.params; + const url = `${API_BASE_URL}/api/services/${serviceId}`; + + const response = await fetch(url, { + method: HTTP_METHODS.DELETE, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to delete organisation service'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error deleting organisation service:', error); + return sendInternalError(); + } +}; + +export const PUT = withAuth(putHandler); +export const DELETE = withAuth(deleteHandler); diff --git a/src/app/api/organisations/[id]/services/route.ts b/src/app/api/organisations/[id]/services/route.ts new file mode 100644 index 0000000..e629865 --- /dev/null +++ b/src/app/api/organisations/[id]/services/route.ts @@ -0,0 +1,72 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const getHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const { id } = context.params; + const url = `${API_BASE_URL}/api/services/provider/${id}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch organisation services'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching organisation services:', error); + return sendInternalError(); + } +}; + +const postHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/services', HTTP_METHODS.POST)) { + return sendForbidden(); + } + + const body = await req.json(); + + const url = `${API_BASE_URL}/api/services`; + + const response = await fetch(url, { + method: HTTP_METHODS.POST, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to create organisation service'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error creating organisation service:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const POST = withAuth(postHandler); diff --git a/src/app/api/organisations/[id]/toggle-published/route.ts b/src/app/api/organisations/[id]/toggle-published/route.ts new file mode 100644 index 0000000..672548c --- /dev/null +++ b/src/app/api/organisations/[id]/toggle-published/route.ts @@ -0,0 +1,40 @@ +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const patchHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PATCH)) { + return sendForbidden(); + } + + const { id } = context.params; + const body = await req.json(); + + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/toggle-published`, { + method: HTTP_METHODS.PATCH, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to toggle published status'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error toggling published status:', error); + return sendInternalError(); + } +}; + +export const PATCH = withAuth(patchHandler); diff --git a/src/app/api/organisations/[id]/toggle-verified/route.ts b/src/app/api/organisations/[id]/toggle-verified/route.ts new file mode 100644 index 0000000..c2fe040 --- /dev/null +++ b/src/app/api/organisations/[id]/toggle-verified/route.ts @@ -0,0 +1,38 @@ +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const patchHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.PATCH)) { + return sendForbidden(); + } + + const { id } = context.params; + + const response = await fetch(`${API_BASE_URL}/api/organisations/${id}/toggle-verified`, { + method: HTTP_METHODS.PATCH, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to toggle verified status'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error toggling verified status:', error); + return sendInternalError(); + } +}; + +export const PATCH = withAuth(patchHandler); diff --git a/src/app/api/organisations/route.ts b/src/app/api/organisations/route.ts new file mode 100644 index 0000000..cd782ad --- /dev/null +++ b/src/app/api/organisations/route.ts @@ -0,0 +1,87 @@ +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; +import { UserAuthClaims } from '@/types/auth'; +import { getUserLocationSlugs } from '@/utils/locationUtils'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + // Forward query parameters + const searchParams = req.nextUrl.searchParams; + + // Add location filtering for CityAdmin users when dropdown is empty (showing all their locations) + const userAuthClaims = auth.session.user.authClaims as UserAuthClaims; + const locationSlugs = getUserLocationSlugs(userAuthClaims, true); + const selectedLocation = searchParams.get('location'); + + // If CityAdmin with specific locations AND no location selected in dropdown + // Pass all their locations to show all users they have access to + if (locationSlugs && locationSlugs.length > 0 && !selectedLocation) { + searchParams.set('locations', locationSlugs.join(',')); + } + + const queryString = searchParams.toString(); + const url = `${API_BASE_URL}/api/organisations${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch organisations'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching organisations:', error); + return sendInternalError(); + } +}; + +const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/organisations', HTTP_METHODS.POST)) { + return sendForbidden(); + } + + const body = await req.json(); + const url = `${API_BASE_URL}/api/organisations`; + + const response = await fetch(url, { + method: HTTP_METHODS.POST, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to create organisation'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error creating organisation:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const POST = withAuth(postHandler); diff --git a/src/app/api/service-categories/route.ts b/src/app/api/service-categories/route.ts new file mode 100644 index 0000000..b406cd5 --- /dev/null +++ b/src/app/api/service-categories/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { sendForbidden, sendError, sendInternalError, proxyResponse } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/service-categories', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const url = `${API_BASE_URL}/api/service-categories`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch service categories'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching service categories:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 9dd60de..6162cef 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -41,7 +41,7 @@ const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, au }); const data = await response.json(); - + if (!response.ok) { return sendError(response.status, data.error || 'Failed to fetch users'); } diff --git a/src/app/banners/page.tsx b/src/app/banners/page.tsx index cb93164..cb88414 100644 --- a/src/app/banners/page.tsx +++ b/src/app/banners/page.tsx @@ -120,8 +120,9 @@ export default function BannersListPage() { setLocations(result.data); } } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load locations'; - console.error('Failed to fetch locations:', errorMessage); + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch locations'; + setError(errorMessage); + errorToast.generic(errorMessage); } }; @@ -247,6 +248,7 @@ export default function BannersListPage() {
setNameInput(e.target.value)} + onKeyPress={handleSearchKeyPress} + className="pl-10" + /> +
+ + + + +
+ + + + + +
+ + + )} + + {/* Results Summary - Hidden for OrgAdmin */} + {!isOrgAdmin && ( +
+

+ {loading ? '' : `${total} organisation${total !== 1 ? 's' : ''} found`} +

+ {!loading && total > 0 && ( + + )} +
+ )} + + {/* Loading State */} + {loading && ( +
+
+
+ )} + + {/* Error State */} + {error && !loading && ( +
+

Error Loading Organisations

+

{error}

+ +
+ )} + + {/* Empty State */} + {!loading && !error && organisations.length === 0 && ( +
+

No Organisations Found

+
+ {searchName || isVerifiedFilter || isPublishedFilter || locationFilter ? ( +

No organisations match your current filters. Try adjusting your search criteria.

+ ) : ( +

No organisations available.

+ )} +
+
+ )} + + {/* Organisations Grid */} + {!loading && !error && organisations.length > 0 && ( +
+ {organisations.map((organisation) => ( + + ))} +
+ )} + + {/* Pagination - Hidden for OrgAdmin */} + {!isOrgAdmin && !loading && !error && totalPages > 1 && ( +
+ +

+ Showing {(currentPage - 1) * limit + 1} - {Math.min(currentPage * limit, total)} of {total} organisations +

+
+ )} + + + {/* Add User to Organisation Modal */} + { + setIsAddUserModalOpen(false); + setSelectedOrganisation(null); + }} + onSuccess={() => { + // Refresh the organisations list + fetchOrganisations(); + }} + organisation={selectedOrganisation} + /> + + {/* Add Organisation Modal */} + setIsAddOrganisationModalOpen(false)} + onSuccess={() => { + // Refresh the organisations list + fetchOrganisations(); + }} + /> + + {/* Edit Organisation Modal */} + {selectedOrganisation && ( + { + setIsEditOrganisationModalOpen(false); + setSelectedOrganisation(null); + }} + organisation={selectedOrganisation} + onOrganisationUpdated={() => { + // Refresh the organisations list + fetchOrganisations(); + }} + /> + )} + + {/* View Organisation Modal (Read-only) */} + {selectedOrganisation && ( + { + setIsViewOrganisationModalOpen(false); + setSelectedOrganisation(null); + }} + organisation={selectedOrganisation} + onOrganisationUpdated={() => { + // Refresh the organisations list + fetchOrganisations(); + }} + viewMode={true} + /> + )} + + {/* Notes Modal */} + { + setIsNotesModalOpen(false); + setSelectedOrganisation(null); + }} + onClearNotes={handleClearNotesClick} + organisation={selectedOrganisation} + /> + + {/* Disable Organisation Modal */} + { + setShowDisableModal(false); + setOrganisationToDisable(null); + }} + onConfirm={confirmDisable} + organisation={organisationToDisable} + /> + + {/* Clear Notes Confirmation Modal */} + setShowClearNotesConfirmModal(false)} + onConfirm={confirmClearNotes} + title="Clear All Notes" + message={`Are you sure you want to clear all notes for "${selectedOrganisation?.Name}"? This action cannot be undone.`} + variant="warning" + confirmLabel="Clear Notes" + cancelLabel="Cancel" + /> ); } diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx index 1b618ef..308a349 100644 --- a/src/app/users/page.tsx +++ b/src/app/users/page.tsx @@ -16,6 +16,7 @@ import { errorToast, successToast, loadingToast, toastUtils } from '@/utils/toas import { authenticatedFetch } from '@/utils/authenticatedFetch'; import { ROLES, getRoleOptions } from '@/constants/roles'; import { HTTP_METHODS } from '@/constants/httpMethods'; +import { ICity } from '@/types'; export default function UsersPage() { // Check authorization FIRST before any other logic @@ -39,7 +40,7 @@ export default function UsersPage() { const [isViewModalOpen, setIsViewModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [locations, setLocations] = useState>([]); + const [locations, setLocations] = useState([]); const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); const [showDeactivateConfirmModal, setShowDeactivateConfirmModal] = useState(false); const [userToDelete, setUserToDelete] = useState(null); @@ -74,8 +75,15 @@ export default function UsersPage() { const data = await response.json(); setLocations(data.data || []); } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch locations'); + } } catch (err) { - console.error('Failed to fetch locations:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch locations'; + setError(errorMessage); + errorToast.generic(errorMessage); } }; @@ -103,7 +111,7 @@ export default function UsersPage() { setTotal(result.pagination?.total || 0); setTotalPages(result.pagination?.pages || 1); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load users'; + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'; setError(errorMessage); errorToast.generic(errorMessage); } finally { @@ -281,6 +289,7 @@ export default function UsersPage() {
{ const file = e.target.files?.[0]; @@ -1108,11 +1109,11 @@ export function BannerEditor({ initialData, onDataChange, onSave, saving = false isOpen={showConfirmModal} onClose={() => setShowConfirmModal(false)} onConfirm={confirmCancel} - title="Cancel Changes" - message="Are you sure you want to cancel? All unsaved changes will be lost." + title="Close without saving?" + message="You may lose unsaved changes." variant="warning" - confirmLabel="Yes, Cancel" - cancelLabel="No, Keep Editing" + confirmLabel="Close Without Saving" + cancelLabel="Continue Editing" />
); diff --git a/src/components/organisations/AccommodationForm.tsx b/src/components/organisations/AccommodationForm.tsx new file mode 100644 index 0000000..305430a --- /dev/null +++ b/src/components/organisations/AccommodationForm.tsx @@ -0,0 +1,354 @@ +'use client'; + +import React, { useState, useEffect, useImperativeHandle, useCallback } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { ValidationError } from '@/components/ui/ErrorDisplay'; +import { IAccommodation, IAccommodationFormData, DiscretionaryValue } from '@/types/organisations/IAccommodation'; +import { AccommodationType } from '@/types/organisations/IAccommodation'; +import { validateAccommodation } from '@/schemas/accommodationSchema'; +import { GeneralInfoSection } from './accommodation-sections/GeneralInfoSection'; +import { ContactDetailsSection } from './accommodation-sections/ContactDetailsSection'; +import { LocationSection } from './accommodation-sections/LocationSection'; +import { PricingSection } from './accommodation-sections/PricingSection'; +import { FeaturesSection } from './accommodation-sections/FeaturesSection'; +import { SupportSection } from './accommodation-sections/SupportSection'; +import { SuitableForSection } from './accommodation-sections/SuitableForSection'; + +export interface AccommodationFormRef { + validate: () => boolean; + getFormData: () => IAccommodationFormData; + resetForm: () => void; +} + +interface AccommodationFormProps { + initialData?: IAccommodation | null; + providerId: string; + availableCities: Array<{ _id: string; Name: string; Key: string }>; + onValidationChange?: (errors: ValidationError[]) => void; + viewMode?: boolean; +} + +interface CollapsibleSectionProps { + title: string; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; + hasErrors?: boolean; +} + +function CollapsibleSection({ title, isOpen, onToggle, children, hasErrors }: CollapsibleSectionProps) { + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +export const AccommodationForm = React.forwardRef(({ + initialData, + providerId, + availableCities, + onValidationChange, + viewMode = false +}, ref) => { + const [validationErrors, setValidationErrors] = useState([]); + + // Section collapse states + const [openSections, setOpenSections] = useState({ + generalInfo: true, + contact: false, + location: false, + pricing: false, + features: false, + support: false, + suitableFor: false + }); + + // Initialize form data based on add/edit mode + const getInitialFormData = useCallback((): IAccommodationFormData => { + if (initialData) { + return { + GeneralInfo: initialData.GeneralInfo, + PricingAndRequirementsInfo: initialData.PricingAndRequirementsInfo, + ContactInformation: initialData.ContactInformation, + Address: initialData.Address, + FeaturesWithDiscretionary: initialData.FeaturesWithDiscretionary, + ResidentCriteriaInfo: initialData.ResidentCriteriaInfo, + SupportProvidedInfo: initialData.SupportProvidedInfo + }; + } + return { + GeneralInfo: { + Name: '', + Synopsis: '', + Description: '', + AccommodationType: '' as AccommodationType, // Empty string for placeholder, will be validated on submit + ServiceProviderId: providerId, + ServiceProviderName: '', + IsOpenAccess: false, + IsPubliclyVisible: true, + IsPublished: false + }, + PricingAndRequirementsInfo: { + ReferralIsRequired: false, + ReferralNotes: '', + Price: '', + FoodIsIncluded: DiscretionaryValue.DontKnowAsk, + AvailabilityOfMeals: '' + }, + ContactInformation: { + Name: '', + Email: '', + Telephone: '', + AdditionalInfo: '' + }, + Address: { + Street1: '', + Street2: '', + Street3: '', + City: '', + Postcode: '', + AssociatedCityId: '' + }, + FeaturesWithDiscretionary: { + AcceptsHousingBenefit: DiscretionaryValue.DontKnowAsk, + AcceptsPets: DiscretionaryValue.DontKnowAsk, + AcceptsCouples: DiscretionaryValue.DontKnowAsk, + HasDisabledAccess: DiscretionaryValue.DontKnowAsk, + IsSuitableForWomen: DiscretionaryValue.DontKnowAsk, + IsSuitableForYoungPeople: DiscretionaryValue.DontKnowAsk, + HasSingleRooms: DiscretionaryValue.DontKnowAsk, + HasSharedRooms: DiscretionaryValue.DontKnowAsk, + HasShowerBathroomFacilities: DiscretionaryValue.DontKnowAsk, + HasAccessToKitchen: DiscretionaryValue.DontKnowAsk, + HasLaundryFacilities: DiscretionaryValue.DontKnowAsk, + HasLounge: DiscretionaryValue.DontKnowAsk, + AllowsVisitors: DiscretionaryValue.DontKnowAsk, + HasOnSiteManager: DiscretionaryValue.DontKnowAsk, + AdditionalFeatures: '' + }, + ResidentCriteriaInfo: { + AcceptsMen: false, + AcceptsWomen: false, + AcceptsCouples: false, + AcceptsYoungPeople: false, + AcceptsFamilies: false, + AcceptsBenefitsClaimants: false + }, + SupportProvidedInfo: { + SupportOffered: [], + SupportInfo: '' + } + }; + }, [initialData, providerId]); + + const [formData, setFormData] = useState(getInitialFormData); + + // Reset form when initialData changes + useEffect(() => { + setFormData(getInitialFormData()); + setValidationErrors([]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialData, providerId]); + + // Notify parent of validation changes + useEffect(() => { + if (onValidationChange) { + onValidationChange(validationErrors); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [validationErrors]); + + const toggleSection = (section: keyof typeof openSections) => { + setOpenSections(prev => ({ ...prev, [section]: !prev[section] })); + }; + + const handleFieldChange = (field: string, value: string | boolean | number | DiscretionaryValue | AccommodationType | string[]) => { + const keys = field.split('.'); + setFormData(prev => { + const newData = { ...prev }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = newData; + + for (let i = 0; i < keys.length - 1; i++) { + current[keys[i]] = { ...current[keys[i]] }; + current = current[keys[i]]; + } + + current[keys[keys.length - 1]] = value; + return newData; + }); + }; + + const getErrorsForSection = (prefix: string): Record => { + const errors: Record = {}; + validationErrors.forEach(error => { + if (error.Path.startsWith(prefix)) { + errors[error.Path] = error.Message; + } + }); + return errors; + }; + + const hasSectionErrors = (prefix: string): boolean => { + return validationErrors.some(error => error.Path.startsWith(prefix)); + }; + + // Expose methods via ref + useImperativeHandle(ref, () => ({ + validate: () => { + // Use schema validation + const result = validateAccommodation(formData); + + if (!result.success) { + const errors = result.errors.map((error: { path: string[] | string; message: string }) => ({ + Path: Array.isArray(error.path) ? error.path.join('.') : error.path, + Message: error.message + })); + setValidationErrors(errors); + return false; + } + + setValidationErrors([]); + return true; + }, + getFormData: () => formData, + resetForm: () => { + setFormData(getInitialFormData()); + setValidationErrors([]); + setOpenSections({ + generalInfo: true, + contact: false, + location: false, + pricing: false, + features: false, + support: false, + suitableFor: false + }); + } + })); + + return ( +
+ {/* General Information */} + toggleSection('generalInfo')} + hasErrors={hasSectionErrors('GeneralInfo')} + > + + + + {/* Contact Details */} + toggleSection('contact')} + hasErrors={hasSectionErrors('ContactInformation')} + > + + + + {/* Location */} + toggleSection('location')} + hasErrors={hasSectionErrors('Address')} + > + + + + {/* Pricing & Requirements */} + toggleSection('pricing')} + hasErrors={hasSectionErrors('PricingAndRequirementsInfo')} + > + + + + {/* Features */} + toggleSection('features')} + hasErrors={hasSectionErrors('FeaturesWithDiscretionary')} + > + + + + {/* Support */} + toggleSection('support')} + hasErrors={hasSectionErrors('SupportProvidedInfo')} + > + + + + {/* Suitable For */} + toggleSection('suitableFor')} + hasErrors={hasSectionErrors('ResidentCriteriaInfo')} + > + + +
+ ); +}); + +AccommodationForm.displayName = 'AccommodationForm'; diff --git a/src/components/organisations/AddAccommodationModal.tsx b/src/components/organisations/AddAccommodationModal.tsx new file mode 100644 index 0000000..bdab6a4 --- /dev/null +++ b/src/components/organisations/AddAccommodationModal.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { IAccommodation } from '@/types/organisations/IAccommodation'; +import { AccommodationForm, AccommodationFormRef } from './AccommodationForm'; +import { successToast, errorToast } from '@/utils/toast'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { transformErrorPath } from '@/schemas/accommodationSchema'; + +interface AddAccommodationModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + organisationId: string; + providerId: string; + availableCities: Array<{ _id: string; Name: string; Key: string }>; + accommodation?: IAccommodation | null; + viewMode?: boolean; // When true, all inputs are disabled and save button hidden +} + +export function AddAccommodationModal({ + isOpen, + onClose, + onSuccess, + organisationId, + providerId, + availableCities, + accommodation, + viewMode = false +}: AddAccommodationModalProps) { + const formRef = useRef(null); + const isEditMode = !!accommodation; + + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [validationErrors, setValidationErrors] = useState([]); + const [showCancelConfirm, setShowConfirmModal] = useState(false); + + const handleValidationChange = (errors: ValidationError[]) => { + // Transform client-side validation paths to user-friendly names + const transformed = errors.map((e) => ({ + Path: transformErrorPath(e.Path), + Message: e.Message, + })); + setValidationErrors(transformed); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formRef.current) return; + + // Validate form + if (!formRef.current.validate()) { + errorToast.validation(); + return; + } + + setIsSubmitting(true); + setErrorMessage(''); + setValidationErrors([]); + + try { + const formData = formRef.current.getFormData(); + + const url = isEditMode + ? `/api/organisations/${organisationId}/accommodations/${accommodation._id}` + : `/api/organisations/${organisationId}/accommodations`; + + const method = isEditMode ? 'PUT' : 'POST'; + + const response = await authenticatedFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.errors && Array.isArray(data.errors)) { + const transformedErrors = data.errors.map((error: { path?: string | string[]; Path?: string; message?: string; Message?: string }) => { + const originalPath = Array.isArray(error.path) ? error.path.join('.') : (error.Path || ''); + return { + Path: transformErrorPath(originalPath), + Message: error.Message || error.message || '' + }; + }); + setValidationErrors(transformedErrors); + errorToast.validation(); + } else { + setErrorMessage(data.error || 'Failed to save accommodation'); + errorToast.create('accommodation', data.error); + } + return; + } + + successToast[isEditMode ? 'update' : 'create'](isEditMode ? 'Accommodation' : 'Accommodation'); + onSuccess(); + handleClose(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save accommodation'; + setErrorMessage(message); + errorToast.generic(message); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (formRef.current) { + formRef.current.resetForm(); + } + setValidationErrors([]); + setErrorMessage(''); + onClose(); + }; + + const handleCancelClick = () => { + setShowConfirmModal(true); + }; + + const confirmCancel = () => { + setShowConfirmModal(false); + handleClose(); + }; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Header */} +
+

+ {viewMode ? 'View Accommodation' : (isEditMode ? 'Edit Accommodation' : 'Add Accommodation')} +

+ +
+ + {/* Form Content - Scrollable */} +
+
+ +
+ + {/* Footer - Fixed at bottom */} + {!viewMode && ( +
+ {/* Error Display */} + + +
+ + +
+
+ )} +
+
+
+ + {/* Confirmation Modal */} + setShowConfirmModal(false)} + title="Close without saving?" + message="You may lose unsaved changes." + confirmLabel="Close Without Saving" + cancelLabel="Continue Editing" + variant="warning" + /> + + ); +} \ No newline at end of file diff --git a/src/components/organisations/AddLocationModal.tsx b/src/components/organisations/AddLocationModal.tsx new file mode 100644 index 0000000..d3b8489 --- /dev/null +++ b/src/components/organisations/AddLocationModal.tsx @@ -0,0 +1,446 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Checkbox } from '@/components/ui/Checkbox'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { IAddressFormData, IOpeningTimeFormData } from '@/types/organisations/IOrganisation'; +import { OpeningTimeFormSchema, AddressSchema } from '@/schemas/organisationSchema'; +import { OpeningTimesManager } from './OpeningTimesManager'; +import { errorToast } from '@/utils/toast'; +import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay'; +import { decodeText } from '@/utils/htmlDecode'; + +interface AddLocationModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (location: IAddressFormData) => void; + editingLocation?: IAddressFormData | null; + validationErrors?: ValidationError[]; + viewMode?: boolean; // When true, all inputs are disabled and save button hidden +} + +export function AddLocationModal({ + isOpen, + onClose, + onSave, + editingLocation = null, + validationErrors: _validationErrors = [], + viewMode = false +}: AddLocationModalProps) { + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [locationErrors, setLocationErrors] = useState([]); + const [currentLocation, setCurrentLocation] = useState({ + Street: '', + Street1: '', + Street2: '', + Street3: '', + City: '', + Postcode: '', + Telephone: '', + IsOpen247: false, + IsAppointmentOnly: false, + OpeningTimes: [] + }); + + // Initialize form when modal opens or editing location changes + useEffect(() => { + if (isOpen) { + if (editingLocation) { + setCurrentLocation({ + ...editingLocation, + Street: decodeText(editingLocation.Street || ''), + Street1: decodeText(editingLocation.Street1 || ''), + Street2: decodeText(editingLocation.Street2 || ''), + Street3: decodeText(editingLocation.Street3 || '') + }); + } else { + resetForm(); + } + } + }, [isOpen, editingLocation]); + + const resetForm = () => { + setCurrentLocation({ + Street: '', + Street1: '', + Street2: '', + Street3: '', + City: '', + Postcode: '', + Telephone: '', + IsOpen247: false, + IsAppointmentOnly: false, + OpeningTimes: [] + }); + setLocationErrors([]); + }; + + const generateLocationKey = (location: IAddressFormData): string => { + const parts = [location.Street, location.City, location.Postcode].filter(part => part && part.trim()); + return parts.join('-').toLowerCase().replace(/[^a-z0-9-]/g, ''); + }; + + const validateLocation = (): boolean => { + const errors: ValidationError[] = []; + + // Validate using AddressSchema for comprehensive validation including Postcode format + const addressValidation = AddressSchema.safeParse(currentLocation); + + if (!addressValidation.success) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addressValidation.error.issues.forEach((issue: any) => { + const fieldName = issue.path[0] as string; + // Skip Key validation errors since it's auto-generated + if (fieldName !== 'Key') { + errors.push({ + Path: fieldName, + Message: issue.message + }); + } + }); + } + + // Validate opening times if not 24/7 + if (!currentLocation.IsOpen247) { + if (currentLocation.OpeningTimes.length === 0) { + errors.push({ Path: 'Opening Times', Message: 'At least one opening time is required when location is not open 24/7' }); + } else { + // Validate each opening time using OpeningTimeFormSchema + for (const openingTime of currentLocation.OpeningTimes) { + const result = OpeningTimeFormSchema.safeParse(openingTime); + if (!result.success) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.error.issues.forEach((issue: any) => { + errors.push({ Path: 'Opening Times', Message: issue.message }); + }); + } + } + } + } + + if (errors.length > 0) { + setLocationErrors(errors); + return false; + } + + setLocationErrors([]); + return true; + }; + + const handleSave = () => { + if (!validateLocation()) { + errorToast.validation(); + return; + } + + const locationWithKey = { + ...currentLocation, + Key: generateLocationKey(currentLocation) + }; + + onSave(locationWithKey); + onClose(); + }; + + const confirmCancel = () => { + setShowConfirmModal(false); + resetForm(); + onClose(); + }; + + const handleOpeningTimesChange = (openingTimes: IOpeningTimeFormData[]) => { + setCurrentLocation({ + ...currentLocation, + OpeningTimes: openingTimes + }); + // Clear errors when opening times change to give immediate feedback + if (locationErrors.length > 0) { + setLocationErrors([]); + } + }; + + const handle24x7Change = (checked: boolean) => { + let openingTimes: IOpeningTimeFormData[] = []; + + if (checked) { + // Generate 24/7 opening times for all days + // IsOpen247 has priority - even if IsAppointmentOnly is also checked + openingTimes = Array.from({ length: 7 }, (_, day) => ({ + Day: day, + StartTime: '00:00', + EndTime: '23:59' + })); + } else if (currentLocation.IsAppointmentOnly) { + // Keep existing opening times when unchecking + openingTimes = currentLocation.OpeningTimes; + } + + setCurrentLocation({ + ...currentLocation, + IsOpen247: checked, + OpeningTimes: openingTimes + }); + }; + + const handleAppointmentOnlyChange = (checked: boolean) => { + // IsAppointmentOnly can now coexist with opening times - don't clear them + setCurrentLocation({ + ...currentLocation, + IsAppointmentOnly: checked + }); + }; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Header */} +
+

+ {viewMode ? 'View Location' : (editingLocation ? 'Edit Location' : 'Add New Location')} +

+ +
+ + {/* Content - scrollable */} +
+
+ {/* Address Fields */} +
+

Address Information

+ +
+
+ + {viewMode ? ( +

+ {currentLocation.Street || '-'} +

+ ) : ( + setCurrentLocation({ + ...currentLocation, + Street: e.target.value + })} + placeholder="Main street address" + /> + )} +
+ +
+ + {viewMode ? ( +

+ {currentLocation.Street1 || '-'} +

+ ) : ( + setCurrentLocation({ + ...currentLocation, + Street1: e.target.value + })} + placeholder="Building name, floor, etc." + /> + )} +
+ +
+ + {viewMode ? ( +

+ {currentLocation.Street2 || '-'} +

+ ) : ( + setCurrentLocation({ + ...currentLocation, + Street2: e.target.value + })} + placeholder="Additional address info" + /> + )} +
+ +
+ + {viewMode ? ( +

+ {currentLocation.Street3 || '-'} +

+ ) : ( + setCurrentLocation({ + ...currentLocation, + Street3: e.target.value + })} + placeholder="Additional address info" + /> + )} +
+ +
+ + setCurrentLocation({ + ...currentLocation, + City: e.target.value + })} + placeholder={viewMode ? '' : 'City'} + disabled={viewMode} + /> +
+ +
+ + setCurrentLocation({ + ...currentLocation, + Postcode: e.target.value + })} + placeholder={viewMode ? '' : 'Postcode'} + disabled={viewMode} + /> +
+ +
+ + setCurrentLocation({ + ...currentLocation, + Telephone: e.target.value + })} + placeholder={viewMode ? '' : 'Telephone number'} + type="tel" + disabled={viewMode} + /> +
+
+
+ + {/* Opening Times Section */} +
+

Opening Times

+ +
+ handle24x7Change(e.target.checked)} + label="Open 24/7" + disabled={viewMode} + /> + + handleAppointmentOnlyChange(e.target.checked)} + label="Appointment Only" + disabled={viewMode} + /> +
+ + {!currentLocation.IsOpen247 && ( + + )} +
+
+
+ + {/* Footer - fixed at bottom */} + {!viewMode && ( +
+ {/* Error Display */} + {locationErrors.length > 0 && ( + + )} + +
+ + +
+
+ )} +
+
+ + {/* Confirmation Modal */} + setShowConfirmModal(false)} + onConfirm={confirmCancel} + title="Close without saving?" + message="You may lose unsaved changes." + confirmLabel="Close Without Saving" + cancelLabel="Continue Editing" + variant="warning" + /> + + ); +} diff --git a/src/components/organisations/AddOrganisationModal.tsx b/src/components/organisations/AddOrganisationModal.tsx new file mode 100644 index 0000000..764e1da --- /dev/null +++ b/src/components/organisations/AddOrganisationModal.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { OrganisationForm, OrganisationFormRef } from './OrganisationForm'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { errorToast, successToast, loadingToast, toastUtils } from '@/utils/toast'; +import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay'; + +interface AddOrganisationModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export function AddOrganisationModal({ isOpen, onClose, onSuccess }: AddOrganisationModalProps) { + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + const formRef = useRef(null); + + const handleValidationChange = (errors: ValidationError[]) => { + setValidationErrors(errors); + }; + + // Helper function to convert time string (HH:MM) to number (HHMM) + const timeStringToNumber = (timeString: string): number => { + return parseInt(timeString.replace(':', '')); + }; + + const handleSave = async () => { + setError(''); + + // Validate form using ref + if (!formRef.current?.validate()) { + errorToast.validation(); + return; + } + + // Get form data using ref + const formData = formRef.current?.getFormData(); + if (!formData) { + errorToast.generic('Failed to get form data'); + return; + } + + setSaving(true); + const toastId = loadingToast.create('organisation'); + + try { + // Convert Tags array to comma-separated string for API + const apiData = { + ...formData, + Tags: formData.Tags.join(','), + // Convert opening times from form format (string) to API format (number) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Addresses: formData.Addresses.map((address: any) => ({ + ...address, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + OpeningTimes: address.OpeningTimes.map((time: any) => ({ + Day: time.Day, + StartTime: timeStringToNumber(time.StartTime), + EndTime: timeStringToNumber(time.EndTime) + })) + })) + }; + + const response = await authenticatedFetch('/api/organisations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(apiData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create organisation'); + } + + // Dismiss loading toast before showing success + toastUtils.dismiss(toastId); + successToast.create('Organisation'); + onSuccess(); + onClose(); + } catch (error) { + // Dismiss loading toast before showing error + toastUtils.dismiss(toastId); + const errorMessage = error instanceof Error ? error.message : 'Failed to create organisation'; + setError(errorMessage); + errorToast.create('organisation', errorMessage); + } finally { + setSaving(false); + } + }; + + const confirmCancel = () => { + setShowConfirmModal(false); + onClose(); + }; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Header */} +
+

Add Organisation

+ +
+ + {/* Content - scrollable */} +
+ +
+ + {/* Footer - fixed at bottom */} +
+ {/* Error Display */} + + +
+ + +
+
+
+
+ + {/* Confirmation Modal */} + setShowConfirmModal(false)} + onConfirm={confirmCancel} + title="Close without saving?" + message="You may lose unsaved changes." + confirmLabel="Close Without Saving" + cancelLabel="Continue Editing" + variant="warning" + /> + + ); +} diff --git a/src/components/organisations/AddServiceModal.tsx b/src/components/organisations/AddServiceModal.tsx new file mode 100644 index 0000000..49e1c9f --- /dev/null +++ b/src/components/organisations/AddServiceModal.tsx @@ -0,0 +1,858 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Textarea } from '@/components/ui/Textarea'; +import { Checkbox } from '@/components/ui/Checkbox'; +import { MultiSelect } from '@/components/ui/MultiSelect'; +import { OpeningTimesManager } from '@/components/organisations/OpeningTimesManager'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import ErrorDisplay, { ValidationError } from '@/components/ui/ErrorDisplay'; +import { IOrganisation } from '@/types/organisations/IOrganisation'; +import { IGroupedService } from '@/types/organisations/IGroupedService'; +import { IServiceCategory } from '@/types/organisations/IServiceCategory'; +import { IOpeningTimeFormData } from '@/types/organisations/IOrganisation'; +import { IGroupedServiceFormData, validateGroupedService, transformErrorPath } from '@/schemas/groupedServiceSchema'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { errorToast, successToast } from '@/utils/toast'; +import { decodeText } from '@/utils/htmlDecode'; + +interface AddServiceModalProps { + isOpen: boolean; + onClose: () => void; + organisation: IOrganisation; + service?: IGroupedService | null; + onServiceSaved: () => void; + viewMode?: boolean; // When true, all inputs are disabled and save button hidden +} + +const AddServiceModal: React.FC = ({ + isOpen, + onClose, + organisation, + service, + onServiceSaved, + viewMode = false +}) => { + const [formData, setFormData] = useState({ + ProviderId: organisation._id, + ProviderName: organisation.Name, + IsPublished: false, + IsVerified: false, + CategoryId: '', + Location: { + IsOutreachLocation: false, + Description: '', + StreetLine1: '', + Postcode: '' + }, + IsOpen247: false, + SubCategories: [], + IsTelephoneService: false, + IsAppointmentOnly: false + }); + + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [showCancelConfirm, setShowConfirmModal] = useState(false); + const [originalData, setOriginalData] = useState(null); + + // Initialize form data when service prop changes + useEffect(() => { + if (service) { + // Convert existing service to form data format + const openingTimes = service.OpeningTimes?.map(ot => ({ + Day: ot.Day, + StartTime: typeof ot.StartTime === 'number' ? + `${Math.floor(ot.StartTime / 100).toString().padStart(2, '0')}:${(ot.StartTime % 100).toString().padStart(2, '0')}` : + ot.StartTime, + EndTime: typeof ot.EndTime === 'number' ? + `${Math.floor(ot.EndTime / 100).toString().padStart(2, '0')}:${(ot.EndTime % 100).toString().padStart(2, '0')}` : + ot.EndTime + })) || []; + + const initialData: IGroupedServiceFormData = { + _id: service._id, + ProviderId: service.ProviderId, + ProviderName: service.ProviderName || organisation.Name, + IsPublished: service.IsPublished, + IsVerified: service.IsVerified, + CategoryId: service.CategoryId, + CategoryName: decodeText(service.CategoryName || ''), + CategorySynopsis: decodeText(service.CategorySynopsis || ''), + Info: decodeText(service.Info || ''), + Tags: service.Tags, + Location: { + IsOutreachLocation: service.Location.IsOutreachLocation || false, + Description: decodeText(service.Location.Description || ''), + StreetLine1: decodeText(service.Location.StreetLine1 || ''), + StreetLine2: service.Location.StreetLine2 || '', + StreetLine3: service.Location.StreetLine3 || '', + StreetLine4: service.Location.StreetLine4 || '', + City: service.Location.City || '', + Postcode: service.Location.Postcode || '', + Location: service.Location.Location ? { + type: service.Location.Location.type, + coordinates: service.Location.Location.coordinates + } : undefined + }, + IsOpen247: service.IsOpen247, + OpeningTimes: openingTimes, + SubCategories: service.SubCategories?.map(sub => ({ + ...sub, + Name: sub.Name || '', + Synopsis: sub.Synopsis || '' + })) || [], + IsTelephoneService: service.IsTelephoneService, + IsAppointmentOnly: service.IsAppointmentOnly, + Telephone: service.Telephone || '' + }; + setFormData(initialData); + setOriginalData(JSON.parse(JSON.stringify(initialData))); + } else { + // Reset form for new service + const initialData: IGroupedServiceFormData = { + ProviderId: organisation.Key, + ProviderName: organisation.Name, + IsPublished: organisation.IsPublished, + IsVerified: organisation.IsVerified, + CategoryId: '', + Location: { + IsOutreachLocation: false, + Description: '', + StreetLine1: '', + Postcode: '' + }, + IsOpen247: false, + SubCategories: [], + IsTelephoneService: false, + IsAppointmentOnly: false, + Telephone: '' + }; + setFormData(initialData); + setOriginalData(JSON.parse(JSON.stringify(initialData))); + } + }, [service, organisation._id, organisation.IsPublished, organisation.IsVerified, organisation.Key, organisation.Name]); + + // Fetch service categories + useEffect(() => { + if (!organisation.Key) return; + + const fetchCategories = async () => { + try { + const response = await authenticatedFetch('/api/service-categories'); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch service categories'); + } + const data = await response.json(); + setCategories(data.data || []); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load service categories'; + errorToast.generic(errorMessage); + } + }; + + if (isOpen) { + fetchCategories(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + // Update selected category when category ID changes + useEffect(() => { + if (formData.CategoryId && categories.length > 0) { + const category = categories.find(cat => cat._id === formData.CategoryId); + setSelectedCategory(category || null); + } else { + setSelectedCategory(null); + } + }, [formData.CategoryId, categories]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateFormData = (field: keyof IGroupedServiceFormData, value: any) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + // Clear validation errors for this field + setValidationErrors(prev => prev.filter(error => !error.Path.startsWith(field))); + }; + + const handleIsOpen247Change = (checked: boolean) => { + let openingTimes: IOpeningTimeFormData[] = []; + + if (checked) { + // Generate 24/7 opening times for all days (Sunday=0 to Saturday=6) + // IsOpen247 has priority - even if IsAppointmentOnly is also checked + openingTimes = Array.from({ length: 7 }, (_, day) => ({ + Day: day, + StartTime: '00:00', + EndTime: '23:59' + })); + } else { + // Keep existing opening times when unchecking + openingTimes = formData.OpeningTimes || []; + } + + setFormData(prev => ({ + ...prev, + IsOpen247: checked, + OpeningTimes: openingTimes + })); + // Clear validation errors + setValidationErrors(prev => prev.filter(error => !error.Path.startsWith('Opening Times'))); + }; + + const handleIsAppointmentOnlyChange = (checked: boolean) => { + // IsAppointmentOnly can now coexist with opening times - don't clear them + setFormData(prev => ({ + ...prev, + IsAppointmentOnly: checked + })); + // Clear validation errors + setValidationErrors(prev => prev.filter(error => !error.Path.startsWith('Opening Times'))); + }; + + const handleCategoryChange = (categoryId: string) => { + const category = categories.find(cat => cat._id === categoryId); + if (category) { + setFormData(prev => ({ + ...prev, + CategoryId: categoryId, + CategoryName: category.Name, + CategorySynopsis: category.Synopsis, + SubCategories: [] + })); + // Clear validation errors + setValidationErrors(prev => prev.filter(error => + !error.Path.startsWith('Category') && !error.Path.startsWith('Sub Categories') + )); + } + }; + + const handleSubCategoriesChange = (selectedIds: string[]) => { + if (!selectedCategory) return; + + const selectedSubCategories = selectedCategory.SubCategories.filter(sub => + selectedIds.includes(sub.Key) + ).map(sub => ({ + _id: sub.Key, + Name: sub.Name, + Synopsis: sub.Synopsis + })); + + setFormData(prev => ({ + ...prev, + SubCategories: selectedSubCategories + })); + + // Clear validation errors for subcategories + setValidationErrors(prev => prev.filter(error => !error.Path.startsWith('Sub Categories'))); + }; + + const handleIsOutreachLocationChange = (isOutreach: boolean) => { + setFormData(prev => ({ + ...prev, + Location: { + ...prev.Location, + IsOutreachLocation: isOutreach, + // Clear fields based on type + Description: isOutreach ? prev.Location.Description : '', + StreetLine1: isOutreach ? '' : prev.Location.StreetLine1, + StreetLine2: isOutreach ? '' : prev.Location.StreetLine2, + StreetLine3: isOutreach ? '' : prev.Location.StreetLine3, + StreetLine4: isOutreach ? '' : prev.Location.StreetLine4, + City: isOutreach ? '' : prev.Location.City, + Postcode: isOutreach ? '' : prev.Location.Postcode, + Location: isOutreach ? undefined : prev.Location.Location + }, + // Clear opening times when switching to outreach or fixed location + IsOpen247: false, + IsAppointmentOnly: false, + OpeningTimes: [] + })); + }; + + const handleAddressSelect = (addressIndex: string) => { + if (addressIndex === '') { + // Clear address fields + setFormData(prev => ({ + ...prev, + Location: { + ...prev.Location, + StreetLine1: '', + StreetLine2: '', + StreetLine3: '', + StreetLine4: '', + City: '', + Postcode: '', + Location: undefined + }, + IsOpen247: false, + IsAppointmentOnly: false, + OpeningTimes: [] + })); + return; + } + + const index = parseInt(addressIndex); + const address = organisation.Addresses[index]; + if (address) { + // Auto-populate location fields AND opening times from selected address + const openingTimes = address.OpeningTimes?.map(ot => ({ + Day: ot.Day, + StartTime: typeof ot.StartTime === 'number' ? + `${Math.floor(ot.StartTime / 100).toString().padStart(2, '0')}:${(ot.StartTime % 100).toString().padStart(2, '0')}` : + String(ot.StartTime), + EndTime: typeof ot.EndTime === 'number' ? + `${Math.floor(ot.EndTime / 100).toString().padStart(2, '0')}:${(ot.EndTime % 100).toString().padStart(2, '0')}` : + String(ot.EndTime) + })) || []; + + setFormData(prev => ({ + ...prev, + Location: { + ...prev.Location, + StreetLine1: address.Street || '', + StreetLine2: address.Street1 || '', + StreetLine3: address.Street2 || '', + StreetLine4: address.Street3 || '', + City: address.City || '', + Postcode: address.Postcode || '', + Location: address.Location ? { + type: address.Location.type, + coordinates: address.Location.coordinates + } : undefined + }, + IsOpen247: address.IsOpen247 || false, + IsAppointmentOnly: address.IsAppointmentOnly || false, + OpeningTimes: openingTimes + })); + } + }; + + const handleOpeningTimesChange = (openingTimes: IOpeningTimeFormData[]) => { + updateFormData('OpeningTimes', openingTimes); + }; + + // Helper function to convert time string (HH:MM) to number (HHMM) + const timeStringToNumber = (timeString: string): number => { + return parseInt(timeString.replace(':', '')); + }; + + const validateForm = (): boolean => { + + // Then validate the rest using GroupedServiceSchema + const result = validateGroupedService(formData); + + // Combine all errors + const allErrors: { Path: string; Message: string }[] = []; + if (!result.success) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serviceErrors = (result.errors || []).map((error: any) => { + const originalPath = Array.isArray(error.path) ? error.path.join('.') : error.path; + return { + Path: transformErrorPath(originalPath), + Message: error.message + }; + }); + allErrors.push(...serviceErrors); + } + + if (allErrors.length > 0) { + setValidationErrors(allErrors); + return false; + } + + setValidationErrors([]); + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + errorToast.validation(); + return; + } + + setIsLoading(true); + + try { + // Convert opening times from string format to number format for API + const submissionData = { + ...formData, + OpeningTimes: formData.OpeningTimes?.map(ot => ({ + Day: ot.Day, + StartTime: timeStringToNumber(ot.StartTime), + EndTime: timeStringToNumber(ot.EndTime) + })) + }; + + const url = service + ? `/api/organisations/${service.ProviderId}/services/${service._id}` + : `/api/organisations/${submissionData.ProviderId}/services`; + + const method = service ? 'PUT' : 'POST'; + + const response = await authenticatedFetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submissionData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Failed to ${service ? 'update' : 'create'} service`); + } + + if (service) { + successToast.update('Service'); + } else { + successToast.create('Service'); + } + + // Reset form to initial state for next use + const initialData: IGroupedServiceFormData = { + ProviderId: organisation.Key, + ProviderName: organisation.Name, + IsPublished: organisation.IsPublished, + IsVerified: organisation.IsVerified, + CategoryId: '', + Location: { + IsOutreachLocation: false, + Description: '', + StreetLine1: '', + Postcode: '' + }, + IsOpen247: false, + SubCategories: [], + IsTelephoneService: false, + IsAppointmentOnly: false, + Telephone: '' + }; + setFormData(initialData); + setOriginalData(JSON.parse(JSON.stringify(initialData))); + setValidationErrors([]); + + onServiceSaved(); // Trigger parent refresh + onClose(); // Close the modal + } catch (error) { + const errorMessage = error instanceof Error ? error.message : `Failed to ${service ? 'update' : 'create'} service`; + errorToast.generic(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleConfirmCancel = () => { + setValidationErrors([]); + setShowConfirmModal(false); + if (originalData) { + setFormData(JSON.parse(JSON.stringify(originalData))); + } + onClose(); + }; + + if (!isOpen) return null; + + const isOutreachLocation = formData.Location.IsOutreachLocation === true; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Header */} +
+

+ {viewMode ? 'View Service' : (service ? 'Edit Service' : 'Add Service')} +

+ +
+ + {/* Content - scrollable */} +
+
+
+ {/* Category Selection */} +
+

Category

+
+
+ + +
+ + {selectedCategory && selectedCategory.SubCategories.length > 0 && ( +
+ + ({ + value: sub.Key, + label: sub.Name + }))} + value={formData.SubCategories.map(sub => sub._id)} + onChange={handleSubCategoriesChange} + placeholder={viewMode ? '' : 'Select subcategories...'} + disabled={viewMode} + /> +
+ )} +
+
+ + {/* Service Details */} +
+

Service Details

+
+
+ +