From 01020861c76b24c64a84636b19e6193215f61e7e Mon Sep 17 00:00:00 2001 From: poduck Date: Sun, 7 Dec 2025 02:23:00 -0500 Subject: [PATCH] feat(staff): Restrict staff permissions and add schedule view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: Restrict staff from accessing resources, customers, services, and tasks APIs - Frontend: Hide management sidebar links from staff members - Add StaffSchedule page with vertical timeline view of appointments - Add StaffHelp page with staff-specific documentation - Return linked_resource_id and can_edit_schedule in user profile for staff 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/.env.development | 1 + frontend/package-lock.json | 77 ++ frontend/package.json | 1 + frontend/src/App.tsx | 30 +- .../src/components/ResourceDetailModal.tsx | 325 +++++++ .../components/Schedule/DraggableEvent.tsx | 4 +- frontend/src/components/Sidebar.tsx | 41 +- frontend/src/hooks/useResourceLocation.ts | 186 ++++ frontend/src/hooks/useResources.ts | 17 +- frontend/src/pages/Resources.tsx | 56 +- frontend/src/pages/StaffSchedule.tsx | 460 +++++++++ frontend/src/pages/help/StaffHelp.tsx | 285 ++++++ frontend/src/types.ts | 2 + mobile/field-app/app/(auth)/jobs.tsx | 139 +-- mobile/field-app/babel.config.js | 1 + mobile/field-app/package-lock.json | 199 +++- mobile/field-app/package.json | 2 + mobile/field-app/src/api/jobs.ts | 41 + .../src/components/DraggableJobBlock.tsx | 415 ++++++++ mobile/field-app/src/types/index.ts | 1 + smoothschedule/.envs/.local/.django | 1 + smoothschedule/config/asgi.py | 7 +- smoothschedule/config/settings/base.py | 1 + smoothschedule/config/settings/local.py | 2 +- .../config/settings/multitenancy.py | 1 + smoothschedule/config/urls.py | 16 +- smoothschedule/core/middleware.py | 25 +- smoothschedule/schedule/consumers.py | 450 +++++++++ .../0029_add_user_can_edit_schedule.py | 23 + smoothschedule/schedule/models.py | 8 + smoothschedule/schedule/routing.py | 13 + smoothschedule/schedule/serializers.py | 2 +- smoothschedule/schedule/signals.py | 89 ++ smoothschedule/schedule/views.py | 235 +++++ .../smoothschedule/field_mobile/__init__.py | 1 + .../smoothschedule/field_mobile/apps.py | 12 + .../field_mobile/migrations/0001_initial.py | 93 ++ .../field_mobile/migrations/__init__.py | 0 .../smoothschedule/field_mobile/models.py | 329 +++++++ .../field_mobile/serializers.py | 514 ++++++++++ .../field_mobile/services/__init__.py | 5 + .../field_mobile/services/status_machine.py | 306 ++++++ .../field_mobile/services/twilio_calls.py | 609 ++++++++++++ .../smoothschedule/field_mobile/tasks.py | 271 ++++++ .../smoothschedule/field_mobile/urls.py | 63 ++ .../smoothschedule/field_mobile/views.py | 887 ++++++++++++++++++ .../smoothschedule/users/api_views.py | 40 +- smoothschedule/tickets/middleware.py | 18 +- 48 files changed, 6156 insertions(+), 148 deletions(-) create mode 100644 frontend/src/components/ResourceDetailModal.tsx create mode 100644 frontend/src/hooks/useResourceLocation.ts create mode 100644 frontend/src/pages/StaffSchedule.tsx create mode 100644 frontend/src/pages/help/StaffHelp.tsx create mode 100644 mobile/field-app/src/components/DraggableJobBlock.tsx create mode 100644 smoothschedule/schedule/consumers.py create mode 100644 smoothschedule/schedule/migrations/0029_add_user_can_edit_schedule.py create mode 100644 smoothschedule/schedule/routing.py create mode 100644 smoothschedule/smoothschedule/field_mobile/__init__.py create mode 100644 smoothschedule/smoothschedule/field_mobile/apps.py create mode 100644 smoothschedule/smoothschedule/field_mobile/migrations/0001_initial.py create mode 100644 smoothschedule/smoothschedule/field_mobile/migrations/__init__.py create mode 100644 smoothschedule/smoothschedule/field_mobile/models.py create mode 100644 smoothschedule/smoothschedule/field_mobile/serializers.py create mode 100644 smoothschedule/smoothschedule/field_mobile/services/__init__.py create mode 100644 smoothschedule/smoothschedule/field_mobile/services/status_machine.py create mode 100644 smoothschedule/smoothschedule/field_mobile/services/twilio_calls.py create mode 100644 smoothschedule/smoothschedule/field_mobile/tasks.py create mode 100644 smoothschedule/smoothschedule/field_mobile/urls.py create mode 100644 smoothschedule/smoothschedule/field_mobile/views.py diff --git a/frontend/.env.development b/frontend/.env.development index 860e33d..20d4b41 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,3 +1,4 @@ VITE_DEV_MODE=true VITE_API_URL=http://api.lvh.me:8000 VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y +VITE_GOOGLE_MAPS_API_KEY= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c668325..a46504f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", + "@react-google-maps/api": "^2.20.7", "@stripe/connect-js": "^3.3.31", "@stripe/react-connect-js": "^3.3.31", "@stripe/react-stripe-js": "^5.4.1", @@ -984,6 +985,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.8", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", + "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==", + "license": "Apache-2.0" + }, + "node_modules/@googlemaps/markerclusterer": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz", + "integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "supercluster": "^8.0.1" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1098,6 +1115,36 @@ "node": ">=18" } }, + "node_modules/@react-google-maps/api": { + "version": "2.20.7", + "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.7.tgz", + "integrity": "sha512-ys7uri3V6gjhYZUI43srHzSKDC6/jiKTwHNlwXFTvjeaJE3M3OaYBt9FZKvJs8qnOhL6i6nD1BKJoi1KrnkCkg==", + "license": "MIT", + "dependencies": { + "@googlemaps/js-api-loader": "1.16.8", + "@googlemaps/markerclusterer": "2.5.3", + "@react-google-maps/infobox": "2.20.0", + "@react-google-maps/marker-clusterer": "2.20.0", + "@types/google.maps": "3.58.1", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19", + "react-dom": "^16.8 || ^17 || ^18 || ^19" + } + }, + "node_modules/@react-google-maps/infobox": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz", + "integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==", + "license": "MIT" + }, + "node_modules/@react-google-maps/marker-clusterer": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz", + "integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==", + "license": "MIT" + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz", @@ -1912,6 +1959,12 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3480,6 +3533,15 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -3623,6 +3685,12 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4797,6 +4865,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c83ca2f..e944473 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", + "@react-google-maps/api": "^2.20.7", "@stripe/connect-js": "^3.3.31", "@stripe/react-connect-js": "^3.3.31", "@stripe/react-stripe-js": "^5.4.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e985cd..3e93375 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer // Import pages const Dashboard = React.lazy(() => import('./pages/Dashboard')); +const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule')); const Scheduler = React.lazy(() => import('./pages/Scheduler')); const Customers = React.lazy(() => import('./pages/Customers')); const Settings = React.lazy(() => import('./pages/Settings')); @@ -97,6 +98,7 @@ const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth' const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling')); const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota')); const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive')); +const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp')); const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule) const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page @@ -667,9 +669,29 @@ const AppContent: React.FC = () => { path="/" element={user.role === 'resource' ? : } /> + {/* Staff Schedule - vertical timeline view */} + + ) : ( + + ) + } + /> } /> } /> - } /> + + ) : ( + + ) + } + /> } /> } /> } /> @@ -752,7 +774,7 @@ const AppContent: React.FC = () => { ) : ( @@ -762,7 +784,7 @@ const AppContent: React.FC = () => { ) : ( @@ -772,7 +794,7 @@ const AppContent: React.FC = () => { ) : ( diff --git a/frontend/src/components/ResourceDetailModal.tsx b/frontend/src/components/ResourceDetailModal.tsx new file mode 100644 index 0000000..8095490 --- /dev/null +++ b/frontend/src/components/ResourceDetailModal.tsx @@ -0,0 +1,325 @@ +/** + * Resource Detail Modal + * + * Shows resource details including a map of the staff member's + * current location when they are en route or in progress. + */ + +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api'; +import { Resource } from '../types'; +import { useResourceLocation, useLiveResourceLocation } from '../hooks/useResourceLocation'; +import Portal from './Portal'; +import { + X, + MapPin, + Navigation, + Clock, + User as UserIcon, + Activity, + Loader2, + AlertCircle, +} from 'lucide-react'; + +interface ResourceDetailModalProps { + resource: Resource; + onClose: () => void; +} + +const mapContainerStyle = { + width: '100%', + height: '300px', + borderRadius: '0.5rem', +}; + +const defaultCenter = { + lat: 39.8283, // Center of US + lng: -98.5795, +}; + +const ResourceDetailModal: React.FC = ({ resource, onClose }) => { + const { t } = useTranslation(); + const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''; + const hasApiKey = googleMapsApiKey.length > 0; + + // Fetch location data + const { data: location, isLoading, error } = useResourceLocation(resource.id); + + // Connect to live updates when tracking is active + useLiveResourceLocation(resource.id, { + enabled: location?.isTracking === true, + }); + + // Load Google Maps API only if we have a key + // When no API key, we skip the hook entirely to avoid warnings + const shouldLoadMaps = hasApiKey; + const { isLoaded: mapsLoaded, loadError: mapsLoadError } = useJsApiLoader({ + googleMapsApiKey: shouldLoadMaps ? googleMapsApiKey : 'SKIP_LOADING', + }); + + // Treat missing API key as if maps failed to load + const effectiveMapsLoaded = shouldLoadMaps && mapsLoaded; + const effectiveMapsError = !shouldLoadMaps || mapsLoadError; + + // Map center based on location + const mapCenter = useMemo(() => { + if (location?.hasLocation && location.latitude && location.longitude) { + return { lat: location.latitude, lng: location.longitude }; + } + return defaultCenter; + }, [location]); + + // Format timestamp + const formattedTimestamp = useMemo(() => { + if (!location?.timestamp) return null; + const date = new Date(location.timestamp); + return date.toLocaleString(); + }, [location?.timestamp]); + + // Status color based on job status + const statusColor = useMemo(() => { + if (!location?.activeJob) return 'gray'; + switch (location.activeJob.status) { + case 'EN_ROUTE': + return 'yellow'; + case 'IN_PROGRESS': + return 'blue'; + default: + return 'gray'; + } + }, [location?.activeJob]); + + return ( + +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {resource.name} +

+

+ {t('resources.staffMember', 'Staff Member')} +

+
+
+ +
+ + {/* Content */} +
+ {/* Active Job Status */} + {location?.activeJob && ( +
+
+ +
+
+ {location.activeJob.statusDisplay} +
+
+ {location.activeJob.title} +
+
+ {location.isTracking && ( + + + {t('resources.liveTracking', 'Live')} + + )} +
+
+ )} + + {/* Map Section */} +
+

+ + {t('resources.currentLocation', 'Current Location')} +

+ + {isLoading ? ( +
+ +
+ ) : error ? ( +
+
+ +

{t('resources.locationError', 'Failed to load location')}

+
+
+ ) : !location?.hasLocation ? ( +
+
+ +

+ {location?.message || t('resources.noLocationData', 'No location data available')} +

+

+ {t('resources.locationHint', 'Location will appear when staff is en route')} +

+
+
+ ) : effectiveMapsError ? ( + // Fallback when Google Maps isn't available - show coordinates +
+
+
+ +
+
+

+ {t('resources.gpsCoordinates', 'GPS Coordinates')} +

+

+ {location.latitude?.toFixed(6)}, {location.longitude?.toFixed(6)} +

+ {location.speed !== undefined && location.speed !== null && ( +

+ {t('resources.speed', 'Speed')}: {(location.speed * 2.237).toFixed(1)} mph +

+ )} + {location.heading !== undefined && location.heading !== null && ( +

+ {t('resources.heading', 'Heading')}: {location.heading.toFixed(0)}° +

+ )} +
+ + + {t('resources.openInMaps', 'Open in Google Maps')} + +
+
+ ) : effectiveMapsLoaded ? ( + + {location.latitude && location.longitude && ( + + )} + + ) : ( +
+ +
+ )} +
+ + {/* Location Details */} + {location?.hasLocation && ( +
+
+
+ + {t('resources.lastUpdate', 'Last Update')} +
+
+ {formattedTimestamp || '-'} +
+
+ + {location.accuracy && ( +
+
+ {t('resources.accuracy', 'Accuracy')} +
+
+ {location.accuracy < 1000 + ? `${location.accuracy.toFixed(0)}m` + : `${(location.accuracy / 1000).toFixed(1)}km`} +
+
+ )} + + {location.speed !== undefined && location.speed !== null && ( +
+
+ {t('resources.speed', 'Speed')} +
+
+ {(location.speed * 2.237).toFixed(1)} mph +
+
+ )} + + {location.heading !== undefined && location.heading !== null && ( +
+
+ {t('resources.heading', 'Heading')} +
+
+ {location.heading.toFixed(0)}° +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+
+ ); +}; + +export default ResourceDetailModal; diff --git a/frontend/src/components/Schedule/DraggableEvent.tsx b/frontend/src/components/Schedule/DraggableEvent.tsx index ce144c5..21313a1 100644 --- a/frontend/src/components/Schedule/DraggableEvent.tsx +++ b/frontend/src/components/Schedule/DraggableEvent.tsx @@ -4,7 +4,9 @@ import { CSS } from '@dnd-kit/utilities'; import { clsx } from 'clsx'; import { Clock, DollarSign } from 'lucide-react'; -export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW'; +// Import from types.ts for consistency +import type { AppointmentStatus } from '../../types'; +export type { AppointmentStatus }; export interface DraggableEventProps { id: number; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 838ca75..b016fea 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -42,7 +42,8 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo const { canUse } = usePlanFeatures(); const canViewAdminPages = role === 'owner' || role === 'manager'; - const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff'; + const canViewManagementPages = role === 'owner' || role === 'manager'; + const isStaff = role === 'staff'; const canViewSettings = role === 'owner'; const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets); @@ -110,19 +111,31 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo isCollapsed={isCollapsed} exact /> - - + {!isStaff && ( + + )} + {!isStaff && ( + + )} + {isStaff && ( + + )} {(role === 'staff' || role === 'resource') && ( { + return useQuery({ + queryKey: ['resourceLocation', resourceId], + queryFn: async () => { + const { data } = await apiClient.get(`/resources/${resourceId}/location/`); + + return { + hasLocation: data.has_location, + latitude: data.latitude, + longitude: data.longitude, + accuracy: data.accuracy, + heading: data.heading, + speed: data.speed, + timestamp: data.timestamp, + isTracking: data.is_tracking ?? false, + activeJob: data.active_job ? { + id: data.active_job.id, + title: data.active_job.title, + status: data.active_job.status, + statusDisplay: data.active_job.status_display, + } : null, + message: data.message, + }; + }, + enabled: !!resourceId && (options?.enabled !== false), + refetchInterval: false, // We'll use WebSocket for live updates instead + staleTime: 30000, // Consider data stale after 30 seconds + }); +}; + +/** + * Hook for live location updates via WebSocket + * + * Connects to WebSocket when enabled and updates the query cache + * when new location data arrives. + */ +export const useLiveResourceLocation = ( + resourceId: string | null, + options?: { enabled?: boolean } +) => { + const queryClient = useQueryClient(); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + const connect = useCallback(() => { + if (!resourceId || options?.enabled === false) return; + + // Get WebSocket URL from current host + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.hostname; + const port = '8000'; // Backend port + const wsUrl = `${protocol}//${host}:${port}/ws/resource-location/${resourceId}/`; + + try { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log('[useLiveResourceLocation] WebSocket connected for resource:', resourceId); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'location_update') { + // Update the query cache with new location data + queryClient.setQueryData(['resourceLocation', resourceId], (old) => ({ + ...old, + hasLocation: true, + latitude: data.latitude, + longitude: data.longitude, + accuracy: data.accuracy, + heading: data.heading, + speed: data.speed, + timestamp: data.timestamp, + isTracking: true, + activeJob: data.active_job ? { + id: data.active_job.id, + title: data.active_job.title, + status: data.active_job.status, + statusDisplay: data.active_job.status_display, + } : old?.activeJob ?? null, + })); + } else if (data.type === 'tracking_stopped') { + // Staff stopped tracking + queryClient.setQueryData(['resourceLocation', resourceId], (old) => ({ + ...old, + hasLocation: old?.hasLocation ?? false, + isTracking: false, + })); + } + } catch (err) { + console.error('[useLiveResourceLocation] Failed to parse message:', err); + } + }; + + ws.onerror = (error) => { + console.error('[useLiveResourceLocation] WebSocket error:', error); + }; + + ws.onclose = (event) => { + console.log('[useLiveResourceLocation] WebSocket closed:', event.code, event.reason); + wsRef.current = null; + + // Reconnect after 5 seconds if not a clean close + if (event.code !== 1000 && options?.enabled !== false) { + reconnectTimeoutRef.current = setTimeout(() => { + connect(); + }, 5000); + } + }; + } catch (err) { + console.error('[useLiveResourceLocation] Failed to connect:', err); + } + }, [resourceId, options?.enabled, queryClient]); + + useEffect(() => { + connect(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(1000, 'Component unmounting'); + wsRef.current = null; + } + }; + }, [connect]); + + // Return a function to manually refresh + return { + refresh: () => queryClient.invalidateQueries({ queryKey: ['resourceLocation', resourceId] }), + }; +}; diff --git a/frontend/src/hooks/useResources.ts b/frontend/src/hooks/useResources.ts index abc5ba0..086fb4c 100644 --- a/frontend/src/hooks/useResources.ts +++ b/frontend/src/hooks/useResources.ts @@ -30,6 +30,7 @@ export const useResources = (filters?: ResourceFilters) => { userId: r.user_id ? String(r.user_id) : undefined, maxConcurrentEvents: r.max_concurrent_events ?? 1, savedLaneCount: r.saved_lane_count, + userCanEditSchedule: r.user_can_edit_schedule ?? false, })); }, }); @@ -51,6 +52,7 @@ export const useResource = (id: string) => { userId: data.user_id ? String(data.user_id) : undefined, maxConcurrentEvents: data.max_concurrent_events ?? 1, savedLaneCount: data.saved_lane_count, + userCanEditSchedule: data.user_can_edit_schedule ?? false, }; }, enabled: !!id, @@ -65,13 +67,23 @@ export const useCreateResource = () => { return useMutation({ mutationFn: async (resourceData: Omit) => { - const backendData = { + const backendData: any = { name: resourceData.name, type: resourceData.type, user: resourceData.userId ? parseInt(resourceData.userId) : null, timezone: 'UTC', // Default timezone }; + if (resourceData.maxConcurrentEvents !== undefined) { + backendData.max_concurrent_events = resourceData.maxConcurrentEvents; + } + if (resourceData.savedLaneCount !== undefined) { + backendData.saved_lane_count = resourceData.savedLaneCount; + } + if (resourceData.userCanEditSchedule !== undefined) { + backendData.user_can_edit_schedule = resourceData.userCanEditSchedule; + } + const { data } = await apiClient.post('/resources/', backendData); return data; }, @@ -101,6 +113,9 @@ export const useUpdateResource = () => { if (updates.savedLaneCount !== undefined) { backendData.saved_lane_count = updates.savedLaneCount; } + if (updates.userCanEditSchedule !== undefined) { + backendData.user_can_edit_schedule = updates.userCanEditSchedule; + } const { data } = await apiClient.patch(`/resources/${id}/`, backendData); return data; diff --git a/frontend/src/pages/Resources.tsx b/frontend/src/pages/Resources.tsx index 36664f1..d09cbbe 100644 --- a/frontend/src/pages/Resources.tsx +++ b/frontend/src/pages/Resources.tsx @@ -6,6 +6,7 @@ import { useResources, useCreateResource, useUpdateResource } from '../hooks/use import { useAppointments } from '../hooks/useAppointments'; import { useStaff, StaffMember } from '../hooks/useStaff'; import ResourceCalendar from '../components/ResourceCalendar'; +import ResourceDetailModal from '../components/ResourceDetailModal'; import Portal from '../components/Portal'; import { getOverQuotaResourceIds } from '../utils/quotaUtils'; import { @@ -18,7 +19,8 @@ import { Settings, X, Pencil, - AlertTriangle + AlertTriangle, + MapPin } from 'lucide-react'; const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => { @@ -46,6 +48,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => const [isModalOpen, setIsModalOpen] = React.useState(false); const [editingResource, setEditingResource] = React.useState(null); const [calendarResource, setCalendarResource] = React.useState<{ id: string; name: string } | null>(null); + const [detailResource, setDetailResource] = React.useState(null); // Calculate over-quota resources (will be auto-archived when grace period ends) const overQuotaResourceIds = useMemo( @@ -60,6 +63,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => const [formMaxConcurrent, setFormMaxConcurrent] = React.useState(1); const [formMultilaneEnabled, setFormMultilaneEnabled] = React.useState(false); const [formSavedLaneCount, setFormSavedLaneCount] = React.useState(undefined); + const [formUserCanEditSchedule, setFormUserCanEditSchedule] = React.useState(false); // Staff selection state const [selectedStaffId, setSelectedStaffId] = useState(null); @@ -181,6 +185,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => setFormMaxConcurrent(editingResource.maxConcurrentEvents); setFormMultilaneEnabled(editingResource.maxConcurrentEvents > 1); setFormSavedLaneCount(editingResource.savedLaneCount); + setFormUserCanEditSchedule(editingResource.userCanEditSchedule ?? false); // Pre-fill staff if editing a STAFF resource if (editingResource.type === 'STAFF' && editingResource.userId) { setSelectedStaffId(editingResource.userId); @@ -197,6 +202,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => setFormMaxConcurrent(1); setFormMultilaneEnabled(false); setFormSavedLaneCount(undefined); + setFormUserCanEditSchedule(false); setSelectedStaffId(null); setStaffSearchQuery(''); setDebouncedSearchQuery(''); @@ -258,6 +264,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => maxConcurrentEvents: number; savedLaneCount: number | undefined; userId?: string; + userCanEditSchedule?: boolean; } = { name: formName, type: formType, @@ -267,6 +274,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => if (formType === 'STAFF' && selectedStaffId) { resourceData.userId = selectedStaffId; + resourceData.userCanEditSchedule = formUserCanEditSchedule; } if (editingResource) { @@ -409,6 +417,15 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) =>
+ {resource.type === 'STAFF' && resource.userId && ( + + )}
)} + {/* Allow User to Edit Schedule Toggle (only for STAFF type) */} + {formType === 'STAFF' && selectedStaffId && ( +
+
+ +

+ {t('resources.allowEditScheduleDescription', 'Let this staff member reschedule and resize their own appointments in the mobile app')} +

+
+ +
+ )} + {/* Submit Buttons */}
); }; diff --git a/frontend/src/pages/StaffSchedule.tsx b/frontend/src/pages/StaffSchedule.tsx new file mode 100644 index 0000000..aa2f600 --- /dev/null +++ b/frontend/src/pages/StaffSchedule.tsx @@ -0,0 +1,460 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + DndContext, + DragEndEvent, + useSensor, + useSensors, + PointerSensor, + DragOverlay, +} from '@dnd-kit/core'; +import { + format, + startOfDay, + endOfDay, + addDays, + subDays, + differenceInMinutes, + addMinutes, + isSameDay, + parseISO, +} from 'date-fns'; +import { + ChevronLeft, + ChevronRight, + Calendar, + Clock, + User, + GripVertical, +} from 'lucide-react'; +import apiClient from '../api/client'; +import { User as UserType } from '../types'; +import toast from 'react-hot-toast'; + +interface StaffScheduleProps { + user: UserType; +} + +interface Job { + id: number; + title: string; + start_time: string; + end_time: string; + status: string; + notes?: string; + customer_name?: string; + service_name?: string; +} + +const HOUR_HEIGHT = 60; // pixels per hour +const START_HOUR = 6; // 6 AM +const END_HOUR = 22; // 10 PM + +const StaffSchedule: React.FC = ({ user }) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [currentDate, setCurrentDate] = useState(new Date()); + const [draggedJob, setDraggedJob] = useState(null); + + const canEditSchedule = user.can_edit_schedule ?? false; + + // Get the resource ID linked to this user (from the user object) + const userResourceId = user.linked_resource_id ?? null; + + // Fetch appointments for the current staff member's resource + const { data: jobs = [], isLoading } = useQuery({ + queryKey: ['staff-jobs', format(currentDate, 'yyyy-MM-dd'), userResourceId], + queryFn: async () => { + if (!userResourceId) return []; + + const start = startOfDay(currentDate).toISOString(); + const end = endOfDay(currentDate).toISOString(); + const response = await apiClient.get('/appointments/', { + params: { + resource: userResourceId, + start_date: start, + end_date: end, + }, + }); + + // Transform to Job format + return response.data.map((apt: any) => ({ + id: apt.id, + title: apt.title || apt.service_name || 'Appointment', + start_time: apt.start_time, + end_time: apt.end_time, + status: apt.status, + notes: apt.notes, + customer_name: apt.customer_name, + service_name: apt.service_name, + })); + }, + enabled: !!userResourceId, + }); + + // Mutation for rescheduling + const rescheduleMutation = useMutation({ + mutationFn: async ({ jobId, newStart }: { jobId: number; newStart: Date }) => { + const job = jobs.find((j) => j.id === jobId); + if (!job) throw new Error('Job not found'); + + const duration = differenceInMinutes(parseISO(job.end_time), parseISO(job.start_time)); + const newEnd = addMinutes(newStart, duration); + + await apiClient.patch(`/appointments/${jobId}/`, { + start_time: newStart.toISOString(), + end_time: newEnd.toISOString(), + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['staff-jobs'] }); + queryClient.invalidateQueries({ queryKey: ['appointments'] }); + toast.success(t('staff.jobRescheduled', 'Job rescheduled successfully')); + }, + onError: () => { + toast.error(t('staff.rescheduleError', 'Failed to reschedule job')); + }, + }); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, + }, + }) + ); + + // Generate time slots + const timeSlots = useMemo(() => { + const slots = []; + for (let hour = START_HOUR; hour <= END_HOUR; hour++) { + slots.push({ + hour, + label: format(new Date().setHours(hour, 0, 0, 0), 'h a'), + }); + } + return slots; + }, []); + + // Calculate job positions + const jobsWithPositions = useMemo(() => { + return jobs + .filter((job) => { + const jobDate = parseISO(job.start_time); + return isSameDay(jobDate, currentDate); + }) + .map((job) => { + const startTime = parseISO(job.start_time); + const endTime = parseISO(job.end_time); + const startHour = startTime.getHours() + startTime.getMinutes() / 60; + const endHour = endTime.getHours() + endTime.getMinutes() / 60; + + const top = (startHour - START_HOUR) * HOUR_HEIGHT; + const height = (endHour - startHour) * HOUR_HEIGHT; + + return { + ...job, + top: Math.max(0, top), + height: Math.max(30, height), + }; + }); + }, [jobs, currentDate]); + + const handleDragStart = (event: any) => { + const jobId = parseInt(event.active.id.toString().replace('job-', '')); + const job = jobs.find((j) => j.id === jobId); + setDraggedJob(job || null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setDraggedJob(null); + + if (!canEditSchedule) return; + + const { active, delta } = event; + if (!active || Math.abs(delta.y) < 10) return; + + const jobId = parseInt(active.id.toString().replace('job-', '')); + const job = jobs.find((j) => j.id === jobId); + if (!job) return; + + // Calculate new time based on drag delta + const minutesDelta = Math.round((delta.y / HOUR_HEIGHT) * 60); + const snappedMinutes = Math.round(minutesDelta / 15) * 15; // Snap to 15-minute intervals + + const originalStart = parseISO(job.start_time); + const newStart = addMinutes(originalStart, snappedMinutes); + + // Validate new time is within bounds + const newHour = newStart.getHours(); + if (newHour < START_HOUR || newHour >= END_HOUR) { + toast.error(t('staff.timeOutOfBounds', 'Cannot schedule outside business hours')); + return; + } + + rescheduleMutation.mutate({ jobId, newStart }); + }; + + const getStatusColor = (status: string) => { + switch (status.toUpperCase()) { + case 'SCHEDULED': + case 'CONFIRMED': + return 'bg-blue-100 border-blue-500 text-blue-800 dark:bg-blue-900/30 dark:border-blue-400 dark:text-blue-300'; + case 'IN_PROGRESS': + return 'bg-yellow-100 border-yellow-500 text-yellow-800 dark:bg-yellow-900/30 dark:border-yellow-400 dark:text-yellow-300'; + case 'COMPLETED': + return 'bg-green-100 border-green-500 text-green-800 dark:bg-green-900/30 dark:border-green-400 dark:text-green-300'; + case 'CANCELLED': + case 'NO_SHOW': + return 'bg-red-100 border-red-500 text-red-800 dark:bg-red-900/30 dark:border-red-400 dark:text-red-300'; + default: + return 'bg-gray-100 border-gray-500 text-gray-800 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300'; + } + }; + + const navigateDate = (direction: 'prev' | 'next') => { + setCurrentDate((d) => (direction === 'prev' ? subDays(d, 1) : addDays(d, 1))); + }; + + const goToToday = () => { + setCurrentDate(new Date()); + }; + + // Show message if no resource is linked + if (!userResourceId) { + return ( +
+
+

+ {t('staff.mySchedule', 'My Schedule')} +

+
+
+
+ +

+ {t('staff.noResourceLinked', 'No Schedule Available')} +

+

+ {t( + 'staff.noResourceLinkedDesc', + 'Your account is not linked to a resource yet. Please contact your manager to set up your schedule.' + )} +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ {t('staff.mySchedule', 'My Schedule')} +

+

+ {canEditSchedule + ? t('staff.dragToReschedule', 'Drag jobs to reschedule them') + : t('staff.viewOnlySchedule', 'View your scheduled jobs for the day')} +

+
+
+ + +
+ + + {format(currentDate, 'EEEE, MMMM d, yyyy')} + +
+ +
+
+
+ + {/* Timeline Content */} +
+ {isLoading ? ( +
+
+
+ ) : ( + +
+
+ {/* Time Column */} +
+ {timeSlots.map((slot) => ( +
+ {slot.label} +
+ ))} +
+ + {/* Events Column */} +
+ {/* Hour Grid Lines */} + {timeSlots.map((slot) => ( +
+ ))} + + {/* Current Time Line */} + {isSameDay(currentDate, new Date()) && ( +
+
+
+ )} + + {/* Jobs */} + {jobsWithPositions.map((job) => ( +
+
+
+
+ {canEditSchedule && ( + + )} +

+ {job.title || job.service_name || 'Appointment'} +

+
+ +
+
+ + + {format(parseISO(job.start_time), 'h:mm a')} -{' '} + {format(parseISO(job.end_time), 'h:mm a')} + +
+ + {job.customer_name && ( +
+ + {job.customer_name} +
+ )} +
+
+ + + {job.status.replace('_', ' ')} + +
+
+ ))} + + {/* Empty State */} + {jobsWithPositions.length === 0 && ( +
+
+ +

+ {t('staff.noJobsToday', 'No jobs scheduled')} +

+

+ {t( + 'staff.noJobsDescription', + 'You have no jobs scheduled for this day' + )} +

+
+
+ )} +
+
+
+ + {/* Drag Overlay */} + + {draggedJob ? ( +
+
+ {draggedJob.title || draggedJob.service_name || 'Appointment'} +
+
+ + + {format(parseISO(draggedJob.start_time), 'h:mm a')} -{' '} + {format(parseISO(draggedJob.end_time), 'h:mm a')} + +
+
+ ) : null} +
+ + )} +
+
+ ); +}; + +export default StaffSchedule; diff --git a/frontend/src/pages/help/StaffHelp.tsx b/frontend/src/pages/help/StaffHelp.tsx new file mode 100644 index 0000000..2794585 --- /dev/null +++ b/frontend/src/pages/help/StaffHelp.tsx @@ -0,0 +1,285 @@ +/** + * Staff Help Guide + * + * Simplified documentation for staff members. + * Only covers features that staff have access to. + */ + +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + ArrowLeft, + BookOpen, + LayoutDashboard, + Calendar, + CalendarOff, + HelpCircle, + CheckCircle, + Clock, + GripVertical, + Ticket, +} from 'lucide-react'; +import { User } from '../../types'; + +interface StaffHelpProps { + user: User; +} + +const StaffHelp: React.FC = ({ user }) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + + const canAccessTickets = user.can_access_tickets ?? false; + const canEditSchedule = user.can_edit_schedule ?? false; + + return ( +
+ {/* Header */} +
+
+
+ +
+ +

+ {t('staffHelp.title', 'Staff Guide')} +

+
+
+
+
+ +
+ {/* Introduction */} +
+
+

+ {t('staffHelp.welcome', 'Welcome to SmoothSchedule')} +

+

+ {t( + 'staffHelp.intro', + 'This guide covers everything you need to know as a staff member. You can view your schedule, manage your availability, and stay updated on your assignments.' + )} +

+
+
+ + {/* Dashboard Section */} +
+
+
+ +
+

+ {t('staffHelp.dashboard.title', 'Dashboard')} +

+
+
+

+ {t( + 'staffHelp.dashboard.description', + "Your dashboard provides a quick overview of your day. Here you can see today's summary and any important updates." + )} +

+
    +
  • + + {t('staffHelp.dashboard.feature1', 'View daily summary and stats')} +
  • +
  • + + {t('staffHelp.dashboard.feature2', 'Quick access to your schedule')} +
  • +
+
+
+ + {/* My Schedule Section */} +
+
+
+ +
+

+ {t('staffHelp.schedule.title', 'My Schedule')} +

+
+
+

+ {t( + 'staffHelp.schedule.description', + 'The My Schedule page shows a vertical timeline of all your jobs for the day. You can navigate between days to see past and future appointments.' + )} +

+ +

+ {t('staffHelp.schedule.features', 'Features')} +

+
    +
  • + + + {t('staffHelp.schedule.feature1', 'See all your jobs in a vertical timeline')} + +
  • +
  • + + + {t( + 'staffHelp.schedule.feature2', + 'View customer name and appointment details' + )} + +
  • +
  • + + + {t('staffHelp.schedule.feature3', 'Navigate between days using arrows')} + +
  • +
  • + + + {t('staffHelp.schedule.feature4', 'See current time indicator on today\'s view')} + +
  • +
+ + {canEditSchedule ? ( +
+

+ + {t('staffHelp.schedule.rescheduleTitle', 'Drag to Reschedule')} +

+

+ {t( + 'staffHelp.schedule.rescheduleDesc', + 'You have permission to reschedule your jobs. Simply drag a job up or down on the timeline to move it to a different time slot. Changes will be saved automatically.' + )} +

+
+ ) : ( +
+

+ {t( + 'staffHelp.schedule.viewOnly', + 'Your schedule is view-only. Contact a manager if you need to reschedule an appointment.' + )} +

+
+ )} +
+
+ + {/* My Availability Section */} +
+
+
+ +
+

+ {t('staffHelp.availability.title', 'My Availability')} +

+
+
+

+ {t( + 'staffHelp.availability.description', + 'Use the My Availability page to set times when you are not available for bookings. This helps managers and the booking system know when not to schedule you.' + )} +

+ +

+ {t('staffHelp.availability.howTo', 'How to Block Time')} +

+
    +
  1. {t('staffHelp.availability.step1', 'Click "Add Time Block" button')}
  2. +
  3. {t('staffHelp.availability.step2', 'Select the date and time range')}
  4. +
  5. {t('staffHelp.availability.step3', 'Add an optional reason (e.g., "Vacation", "Doctor appointment")')}
  6. +
  7. {t('staffHelp.availability.step4', 'Choose if it repeats (one-time, weekly, etc.)')}
  8. +
  9. {t('staffHelp.availability.step5', 'Save your time block')}
  10. +
+ +
+

+ {t('staffHelp.availability.note', 'Note:')}{' '} + {t( + 'staffHelp.availability.noteDesc', + 'Time blocks you create will prevent new bookings during those times. Existing appointments are not affected.' + )} +

+
+
+
+ + {/* Tickets Section - Only if user has access */} + {canAccessTickets && ( +
+
+
+ +
+

+ {t('staffHelp.tickets.title', 'Tickets')} +

+
+
+

+ {t( + 'staffHelp.tickets.description', + 'You have access to the ticketing system. Use tickets to communicate with customers, report issues, or track requests.' + )} +

+
    +
  • + + {t('staffHelp.tickets.feature1', 'View and respond to tickets')} +
  • +
  • + + {t('staffHelp.tickets.feature2', 'Create new tickets for customer issues')} +
  • +
  • + + {t('staffHelp.tickets.feature3', 'Track ticket status and history')} +
  • +
+
+
+ )} + + {/* Help Footer */} +
+ +

+ {t('staffHelp.footer.title', 'Need More Help?')} +

+

+ {t( + 'staffHelp.footer.description', + "If you have questions or need assistance, please contact your manager or supervisor." + )} +

+ {canAccessTickets && ( + + )} +
+
+
+ ); +}; + +export default StaffHelp; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8851ec4..e651619 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -124,6 +124,8 @@ export interface User { notification_preferences?: NotificationPreferences; can_invite_staff?: boolean; can_access_tickets?: boolean; + can_edit_schedule?: boolean; + linked_resource_id?: number; permissions?: Record; quota_overages?: QuotaOverage[]; } diff --git a/mobile/field-app/app/(auth)/jobs.tsx b/mobile/field-app/app/(auth)/jobs.tsx index dc09f5a..b973736 100644 --- a/mobile/field-app/app/(auth)/jobs.tsx +++ b/mobile/field-app/app/(auth)/jobs.tsx @@ -2,6 +2,7 @@ * Jobs List Screen * * Displays jobs in a timeline view with day/week toggle. + * Supports drag-and-drop rescheduling and resize if user has permission. */ import { useCallback, useState, useRef, useMemo } from 'react'; @@ -16,12 +17,15 @@ import { Alert, Dimensions, } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; import { Ionicons } from '@expo/vector-icons'; -import { getJobColor, jobStatusColors } from '../../src/api/jobs'; +import { getJobColor, updateAppointmentTime } from '../../src/api/jobs'; import { useAuth } from '../../src/hooks/useAuth'; import { useJobs } from '../../src/hooks/useJobs'; +import { DraggableJobBlock } from '../../src/components/DraggableJobBlock'; import type { JobListItem, JobStatus } from '../../src/types'; const HOUR_HEIGHT = 60; @@ -149,91 +153,8 @@ function getWeekDays(baseDate: Date): Date[] { return days; } -function JobBlock({ - job, - onPress, - viewMode, - dayIndex = 0, - laneIndex = 0, - totalLanes = 1, -}: { - job: JobListItem; - onPress: () => void; - viewMode: ViewMode; - dayIndex?: number; - laneIndex?: number; - totalLanes?: number; -}) { - // Use time-aware color function (shows red for overdue, yellow for in-progress window, etc.) - const statusColor = getJobColor(job); - - const startDate = new Date(job.start_time); - const endDate = new Date(job.end_time); - - const startHour = startDate.getHours() + startDate.getMinutes() / 60; - const endHour = endDate.getHours() + endDate.getMinutes() / 60; - - const top = startHour * HOUR_HEIGHT; - const height = Math.max((endHour - startHour) * HOUR_HEIGHT, 40); - - // Calculate width and position based on lanes - let blockStyle: { left: number; width: number }; - - if (viewMode === 'week') { - // Week view: divide the day column by lanes - const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes; - blockStyle = { - left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth, - width: laneWidth - 2, - }; - } else { - // Day view: divide the full width by lanes - const laneWidth = DAY_VIEW_WIDTH / totalLanes; - blockStyle = { - left: laneIndex * laneWidth, - width: laneWidth - 4, - }; - } - - return ( - - - - {formatTime(job.start_time)} - - - - - {job.title} - - {viewMode === 'day' && job.customer_name && ( - - {job.customer_name} - - )} - {viewMode === 'day' && job.address && ( - - {job.address} - - )} - - ); -} +// Note: JobBlock functionality moved to DraggableJobBlock component +// which supports drag-and-drop rescheduling and resize function TimelineGrid({ viewMode }: { viewMode: ViewMode }) { const hours = []; @@ -302,12 +223,16 @@ function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) { export default function JobsScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); + const queryClient = useQueryClient(); const { user, logout } = useAuth(); const [refreshing, setRefreshing] = useState(false); const [viewMode, setViewMode] = useState('day'); const [selectedDate, setSelectedDate] = useState(new Date()); const scrollRef = useRef(null); + // Check if user can edit schedule + const canEditSchedule = user?.can_edit_schedule ?? false; + const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]); // Calculate the date range to fetch based on view mode @@ -374,6 +299,24 @@ export default function JobsScreen() { ); }; + // Handle drag-and-drop time changes + const handleTimeChange = useCallback(async ( + jobId: number, + newStartTime: Date, + newEndTime: Date + ) => { + try { + await updateAppointmentTime(jobId, { + start_time: newStartTime.toISOString(), + end_time: newEndTime.toISOString(), + }); + // Invalidate queries to refresh the data + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + } catch (error: any) { + throw new Error(error.response?.data?.detail || 'Failed to update appointment'); + } + }, [queryClient]); + // Scroll to current time on mount const scrollToNow = useCallback(() => { const now = new Date(); @@ -409,23 +352,25 @@ export default function JobsScreen() { const jobsByDay = useMemo(() => { if (viewMode !== 'week') return {}; - const grouped: Record = {}; + // First group raw jobs by day + const tempGrouped: Record = {}; weekDays.forEach((_, index) => { - grouped[index] = []; + tempGrouped[index] = []; }); filteredJobs.forEach(job => { const jobDate = new Date(job.start_time); const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate)); if (dayIndex !== -1) { - grouped[dayIndex].push(job); + tempGrouped[dayIndex].push(job); } }); - // Calculate lane layout for each day - Object.keys(grouped).forEach(key => { + // Then calculate lane layout for each day + const grouped: Record = {}; + Object.keys(tempGrouped).forEach(key => { const dayIndex = parseInt(key); - grouped[dayIndex] = calculateLaneLayout(grouped[dayIndex]); + grouped[dayIndex] = calculateLaneLayout(tempGrouped[dayIndex]); }); return grouped; @@ -459,6 +404,7 @@ export default function JobsScreen() { }); return ( + @@ -577,10 +523,12 @@ export default function JobsScreen() { ]}> {viewMode === 'day' ? ( dayJobsWithLanes.map((job) => ( - handleJobPress(job.id)} + onTimeChange={handleTimeChange} + canEdit={canEditSchedule} viewMode={viewMode} laneIndex={job.laneIndex} totalLanes={job.totalLanes} @@ -589,10 +537,12 @@ export default function JobsScreen() { ) : ( Object.entries(jobsByDay).map(([dayIndex, jobs]) => jobs.map((job) => ( - handleJobPress(job.id)} + onTimeChange={handleTimeChange} + canEdit={false} viewMode={viewMode} dayIndex={parseInt(dayIndex)} laneIndex={job.laneIndex} @@ -616,6 +566,7 @@ export default function JobsScreen() { )} + ); } diff --git a/mobile/field-app/babel.config.js b/mobile/field-app/babel.config.js index 9d89e13..d872de3 100644 --- a/mobile/field-app/babel.config.js +++ b/mobile/field-app/babel.config.js @@ -2,5 +2,6 @@ module.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], }; }; diff --git a/mobile/field-app/package-lock.json b/mobile/field-app/package-lock.json index a9cd92b..986ac60 100644 --- a/mobile/field-app/package-lock.json +++ b/mobile/field-app/package-lock.json @@ -25,7 +25,9 @@ "expo-task-manager": "~14.0.9", "react": "^19.1.0", "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", "react-native-maps": "1.20.1", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0" }, @@ -1366,6 +1368,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", @@ -1513,6 +1531,18 @@ "node": ">=6.9.0" } }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -3151,6 +3181,12 @@ "@types/node": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3202,7 +3238,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -5020,7 +5056,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -7292,6 +7328,21 @@ "hermes-estree": "0.32.0" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -10022,9 +10073,9 @@ } }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10040,6 +10091,19 @@ "ws": "^7" } }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -10121,6 +10185,21 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", + "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", @@ -10153,6 +10232,34 @@ } } }, + "node_modules/react-native-reanimated": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", + "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1", + "semver": "7.7.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0", + "react": "*", + "react-native": "*", + "react-native-worklets": ">=0.5.0" + } + }, + "node_modules/react-native-reanimated/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", @@ -10178,6 +10285,81 @@ "react-native": "*" } }, + "node_modules/react-native-worklets": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.1.tgz", + "integrity": "sha512-KNsvR48ULg73QhTlmwPbdJLPsWcyBotrGPsrDRDswb5FYpQaJEThUKc2ncXE4UM5dn/ewLoQHjSjLaKUVPxPhA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/plugin-transform-arrow-functions": "7.27.1", + "@babel/plugin-transform-class-properties": "7.27.1", + "@babel/plugin-transform-classes": "7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", + "@babel/plugin-transform-optional-chaining": "7.27.1", + "@babel/plugin-transform-shorthand-properties": "7.27.1", + "@babel/plugin-transform-template-literals": "7.27.1", + "@babel/plugin-transform-unicode-regex": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "convert-source-map": "2.0.0", + "semver": "7.7.3" + }, + "peerDependencies": { + "@babel/core": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", @@ -10676,6 +10858,13 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/mobile/field-app/package.json b/mobile/field-app/package.json index 34a1c8a..6ebc7b4 100644 --- a/mobile/field-app/package.json +++ b/mobile/field-app/package.json @@ -28,7 +28,9 @@ "expo-task-manager": "~14.0.9", "react": "^19.1.0", "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", "react-native-maps": "1.20.1", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0" }, diff --git a/mobile/field-app/src/api/jobs.ts b/mobile/field-app/src/api/jobs.ts index 49cb6db..929d06d 100644 --- a/mobile/field-app/src/api/jobs.ts +++ b/mobile/field-app/src/api/jobs.ts @@ -185,3 +185,44 @@ export async function rescheduleJob( ); return response.data; } + +/** + * Update an appointment's time via PATCH to /appointments/{id}/ + * Used for drag-and-drop rescheduling on the mobile timeline + */ +export async function updateAppointmentTime( + appointmentId: number, + data: { start_time: string; end_time: string } +): Promise { + const token = await getAuthToken(); + const userData = await getUserData(); + const subdomain = userData?.business_subdomain; + const apiUrl = getAppointmentsApiUrl(); + + const response = await axios.patch(`${apiUrl}/appointments/${appointmentId}/`, data, { + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Token ${token}` }), + ...(subdomain && { 'X-Business-Subdomain': subdomain }), + }, + }); + + // Transform response to match JobListItem format + const apt = response.data; + const start = new Date(apt.start_time); + const end = new Date(apt.end_time); + const durationMinutes = Math.round((end.getTime() - start.getTime()) / 60000); + + return { + id: apt.id, + title: apt.title || apt.service_name || 'Appointment', + start_time: apt.start_time, + end_time: apt.end_time, + status: apt.status as JobStatus, + status_display: jobStatusLabels[apt.status as JobStatus] || apt.status, + customer_name: apt.customer_name || null, + address: apt.address || apt.location || null, + service_name: apt.service_name || null, + duration_minutes: durationMinutes, + }; +} diff --git a/mobile/field-app/src/components/DraggableJobBlock.tsx b/mobile/field-app/src/components/DraggableJobBlock.tsx new file mode 100644 index 0000000..ba33552 --- /dev/null +++ b/mobile/field-app/src/components/DraggableJobBlock.tsx @@ -0,0 +1,415 @@ +/** + * DraggableJobBlock Component + * + * Allows drag-and-drop rescheduling and resize of appointments on the timeline. + * Features: + * - Drag to move: Changes start/end time while preserving duration + * - Drag edges to resize: Changes duration + * - 15-minute snap: All changes snap to 15-minute intervals + * - Permission check: Only allows editing if user has permission + */ + +import React, { useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + Alert, + Dimensions, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + runOnJS, +} from 'react-native-reanimated'; +import { getJobColor } from '../api/jobs'; +import type { JobListItem } from '../types'; + +const HOUR_HEIGHT = 60; +const SNAP_MINUTES = 15; +const RESIZE_HANDLE_HEIGHT = 12; +const SCREEN_WIDTH = Dimensions.get('window').width; +const DAY_VIEW_WIDTH = SCREEN_WIDTH - 70; +const DAY_COLUMN_WIDTH = (SCREEN_WIDTH - 50) / 7; + +// Convert pixels to minutes +function pixelsToMinutes(pixels: number): number { + return (pixels / HOUR_HEIGHT) * 60; +} + +// Convert minutes to pixels +function minutesToPixels(minutes: number): number { + return (minutes / 60) * HOUR_HEIGHT; +} + +// Snap to nearest 15 minutes +function snapToInterval(minutes: number): number { + return Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES; +} + +// Format time for display +function formatTime(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +interface DraggableJobBlockProps { + job: JobListItem; + onPress: () => void; + onTimeChange: (jobId: number, newStartTime: Date, newEndTime: Date) => Promise; + canEdit: boolean; + viewMode: 'day' | 'week'; + dayIndex?: number; + laneIndex?: number; + totalLanes?: number; +} + +export function DraggableJobBlock({ + job, + onPress, + onTimeChange, + canEdit, + viewMode, + dayIndex = 0, + laneIndex = 0, + totalLanes = 1, +}: DraggableJobBlockProps) { + const statusColor = getJobColor(job); + + const startDate = new Date(job.start_time); + const endDate = new Date(job.end_time); + + const startHour = startDate.getHours() + startDate.getMinutes() / 60; + const endHour = endDate.getHours() + endDate.getMinutes() / 60; + + const initialTop = startHour * HOUR_HEIGHT; + const initialHeight = Math.max((endHour - startHour) * HOUR_HEIGHT, 40); + + // Shared values for animations + const translateY = useSharedValue(0); + const blockHeight = useSharedValue(initialHeight); + const isActive = useSharedValue(false); + const resizeMode = useSharedValue<'none' | 'top' | 'bottom'>('none'); + + // Calculate width and position based on lanes + const blockStyle = useMemo(() => { + if (viewMode === 'week') { + const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes; + return { + left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth, + width: laneWidth - 2, + }; + } else { + const laneWidth = DAY_VIEW_WIDTH / totalLanes; + return { + left: laneIndex * laneWidth, + width: laneWidth - 4, + }; + } + }, [viewMode, dayIndex, laneIndex, totalLanes]); + + // Calculate new times from current position + const calculateNewTimes = useCallback(( + deltaY: number, + heightDelta: number, + mode: 'none' | 'top' | 'bottom' + ): { newStart: Date; newEnd: Date } => { + const deltaMinutes = pixelsToMinutes(deltaY); + const heightDeltaMinutes = pixelsToMinutes(heightDelta); + + let newStartMinutes = startDate.getHours() * 60 + startDate.getMinutes(); + let newEndMinutes = endDate.getHours() * 60 + endDate.getMinutes(); + + if (mode === 'none') { + // Moving the whole block + const snappedDelta = snapToInterval(deltaMinutes); + newStartMinutes += snappedDelta; + newEndMinutes += snappedDelta; + } else if (mode === 'top') { + // Resizing from top + const snappedDelta = snapToInterval(deltaMinutes); + newStartMinutes += snappedDelta; + // Minimum 15 minutes duration + if (newEndMinutes - newStartMinutes < SNAP_MINUTES) { + newStartMinutes = newEndMinutes - SNAP_MINUTES; + } + } else if (mode === 'bottom') { + // Resizing from bottom + const snappedDelta = snapToInterval(heightDeltaMinutes); + newEndMinutes = (startDate.getHours() * 60 + startDate.getMinutes()) + + snapToInterval((endHour - startHour) * 60 + heightDeltaMinutes); + // Minimum 15 minutes duration + if (newEndMinutes - newStartMinutes < SNAP_MINUTES) { + newEndMinutes = newStartMinutes + SNAP_MINUTES; + } + } + + // Clamp to valid day range (0:00 - 23:59) + newStartMinutes = Math.max(0, Math.min(newStartMinutes, 24 * 60 - SNAP_MINUTES)); + newEndMinutes = Math.max(SNAP_MINUTES, Math.min(newEndMinutes, 24 * 60)); + + const newStart = new Date(startDate); + newStart.setHours(Math.floor(newStartMinutes / 60), newStartMinutes % 60, 0, 0); + + const newEnd = new Date(endDate); + newEnd.setHours(Math.floor(newEndMinutes / 60), newEndMinutes % 60, 0, 0); + + return { newStart, newEnd }; + }, [startDate, endDate, startHour, endHour]); + + // Handle the end of a gesture + const handleGestureEnd = useCallback(async ( + deltaY: number, + heightDelta: number, + mode: 'none' | 'top' | 'bottom' + ) => { + if (!canEdit) return; + + const { newStart, newEnd } = calculateNewTimes(deltaY, heightDelta, mode); + + // Check if times actually changed + if (newStart.getTime() === startDate.getTime() && newEnd.getTime() === endDate.getTime()) { + return; + } + + try { + await onTimeChange(job.id, newStart, newEnd); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to update appointment time'); + } + }, [canEdit, calculateNewTimes, startDate, endDate, job.id, onTimeChange]); + + // Main drag gesture (move the whole block) + const dragGesture = Gesture.Pan() + .enabled(canEdit) + .onStart(() => { + isActive.value = true; + resizeMode.value = 'none'; + }) + .onUpdate((event) => { + if (resizeMode.value === 'none') { + // Snap to 15-minute intervals while dragging + const snappedY = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY))); + translateY.value = snappedY; + } + }) + .onEnd((event) => { + isActive.value = false; + const finalY = translateY.value; + translateY.value = withSpring(0, { damping: 20 }); + runOnJS(handleGestureEnd)(finalY, 0, 'none'); + }); + + // Top resize gesture + const topResizeGesture = Gesture.Pan() + .enabled(canEdit) + .onStart(() => { + isActive.value = true; + resizeMode.value = 'top'; + }) + .onUpdate((event) => { + const snappedY = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY))); + translateY.value = snappedY; + // Height decreases as top moves down + const newHeight = Math.max(initialHeight - snappedY, minutesToPixels(SNAP_MINUTES)); + blockHeight.value = newHeight; + }) + .onEnd((event) => { + isActive.value = false; + const finalY = translateY.value; + translateY.value = withSpring(0, { damping: 20 }); + blockHeight.value = withSpring(initialHeight, { damping: 20 }); + runOnJS(handleGestureEnd)(finalY, 0, 'top'); + }); + + // Bottom resize gesture + const bottomResizeGesture = Gesture.Pan() + .enabled(canEdit) + .onStart(() => { + isActive.value = true; + resizeMode.value = 'bottom'; + }) + .onUpdate((event) => { + const snappedDelta = minutesToPixels(snapToInterval(pixelsToMinutes(event.translationY))); + const newHeight = Math.max(initialHeight + snappedDelta, minutesToPixels(SNAP_MINUTES)); + blockHeight.value = newHeight; + }) + .onEnd((event) => { + isActive.value = false; + const heightDelta = blockHeight.value - initialHeight; + blockHeight.value = withSpring(initialHeight, { damping: 20 }); + runOnJS(handleGestureEnd)(0, heightDelta, 'bottom'); + }); + + // Tap gesture for navigation + const tapGesture = Gesture.Tap() + .onEnd(() => { + runOnJS(onPress)(); + }); + + // Combine gestures + const composedGesture = Gesture.Race( + tapGesture, + dragGesture + ); + + // Animated styles + const animatedBlockStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + height: blockHeight.value, + opacity: isActive.value ? 0.8 : 1, + zIndex: isActive.value ? 100 : 1, + })); + + return ( + + + {/* Top resize handle */} + {canEdit && ( + + + + + + )} + + {/* Content */} + + + + {formatTime(job.start_time)} + + + + + {job.title} + + {viewMode === 'day' && job.customer_name && ( + + {job.customer_name} + + )} + {viewMode === 'day' && job.address && ( + + {job.address} + + )} + + + {/* Bottom resize handle */} + {canEdit && ( + + + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + jobBlock: { + position: 'absolute', + backgroundColor: '#fff', + borderRadius: 6, + borderLeftWidth: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + overflow: 'visible', + }, + content: { + flex: 1, + padding: 6, + paddingTop: RESIZE_HANDLE_HEIGHT / 2, + paddingBottom: RESIZE_HANDLE_HEIGHT / 2, + }, + resizeHandleTop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: RESIZE_HANDLE_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + }, + resizeHandleBottom: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: RESIZE_HANDLE_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + }, + resizeBar: { + width: 30, + height: 3, + backgroundColor: '#d1d5db', + borderRadius: 2, + }, + jobBlockHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 2, + }, + jobBlockTime: { + fontSize: 10, + color: '#6b7280', + fontWeight: '500', + }, + jobBlockTimeSmall: { + fontSize: 9, + }, + statusDot: { + width: 6, + height: 6, + borderRadius: 3, + }, + jobBlockTitle: { + fontSize: 13, + fontWeight: '600', + color: '#111827', + marginBottom: 2, + }, + jobBlockTitleSmall: { + fontSize: 10, + }, + jobBlockCustomer: { + fontSize: 11, + color: '#4b5563', + }, + jobBlockAddress: { + fontSize: 10, + color: '#9ca3af', + marginTop: 2, + }, +}); + +export default DraggableJobBlock; diff --git a/mobile/field-app/src/types/index.ts b/mobile/field-app/src/types/index.ts index 66a817d..c9a45bb 100644 --- a/mobile/field-app/src/types/index.ts +++ b/mobile/field-app/src/types/index.ts @@ -12,6 +12,7 @@ export interface User { business_name?: string; business_subdomain?: string; can_use_masked_calls?: boolean; + can_edit_schedule?: boolean; } export interface AuthResponse { diff --git a/smoothschedule/.envs/.local/.django b/smoothschedule/.envs/.local/.django index 557c792..5f3a314 100644 --- a/smoothschedule/.envs/.local/.django +++ b/smoothschedule/.envs/.local/.django @@ -1,6 +1,7 @@ # General # ------------------------------------------------------------------------------ USE_DOCKER=yes +DJANGO_CORS_ALLOW_ALL_ORIGINS=True IPYTHONDIR=/app/.ipython # Redis # ------------------------------------------------------------------------------ diff --git a/smoothschedule/config/asgi.py b/smoothschedule/config/asgi.py index bed95f7..f4e2aff 100644 --- a/smoothschedule/config/asgi.py +++ b/smoothschedule/config/asgi.py @@ -10,18 +10,19 @@ django_asgi_app = get_asgi_application() from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter -from tickets import routing as tickets_routing # Assuming we'll have tickets routing +from tickets import routing as tickets_routing +from schedule import routing as schedule_routing from tickets.middleware import TokenAuthMiddleware application = ProtocolTypeRouter( { "http": django_asgi_app, - # Just HTTP for now. (We can add other protocols later.) "websocket": AuthMiddlewareStack( TokenAuthMiddleware( URLRouter( - tickets_routing.websocket_urlpatterns # Include ticket-specific WebSocket routes + tickets_routing.websocket_urlpatterns + + schedule_routing.websocket_urlpatterns ) ) ), diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py index 655c0cb..b93eeba 100644 --- a/smoothschedule/config/settings/base.py +++ b/smoothschedule/config/settings/base.py @@ -106,6 +106,7 @@ LOCAL_APPS = [ "notifications", # New: Generic notification app "tickets", # New: Support tickets app "smoothschedule.comms_credits", # Communication credits and SMS/calling + "smoothschedule.field_mobile", # Field employee mobile app # Your stuff: custom apps go here ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps diff --git a/smoothschedule/config/settings/local.py b/smoothschedule/config/settings/local.py index e58b499..f42da3f 100644 --- a/smoothschedule/config/settings/local.py +++ b/smoothschedule/config/settings/local.py @@ -56,7 +56,7 @@ SECRET_KEY = env( default="JETIHIJaLl2niIyj134Crg2S2dTURSzyXtd02XPicYcjaK5lJb1otLmNHqs6ZVs0", ) # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me"] # noqa: S104 +ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me", "10.0.1.242"] # noqa: S104 # CORS and CSRF are configured in base.py with environment variable overrides # Local development uses the .env file to set DJANGO_CORS_ALLOWED_ORIGINS diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py index 60765c4..8a0b9be 100644 --- a/smoothschedule/config/settings/multitenancy.py +++ b/smoothschedule/config/settings/multitenancy.py @@ -49,6 +49,7 @@ SHARED_APPS = [ 'notifications', # Notification system - shared for platform to notify tenants 'smoothschedule.public_api', # Public API v1 for third-party integrations 'smoothschedule.comms_credits', # Communication credits (SMS/calling) - shared for billing + 'smoothschedule.field_mobile', # Field employee mobile app - shared for location tracking ] # Tenant-specific apps - Each tenant gets isolated data in their own schema diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py index fd47a8d..a4863e9 100644 --- a/smoothschedule/config/urls.py +++ b/smoothschedule/config/urls.py @@ -68,6 +68,13 @@ urlpatterns = [ # API URLS urlpatterns += [ + # Staff Invitations API - MUST come before schedule.urls to avoid conflict with /staff/ viewset + path("staff/invitations/", staff_invitations_view, name="staff_invitations"), + path("staff/invitations//", cancel_invitation_view, name="cancel_invitation"), + path("staff/invitations//resend/", resend_invitation_view, name="resend_invitation"), + path("staff/invitations/token//", invitation_details_view, name="invitation_details"), + path("staff/invitations/token//accept/", accept_invitation_view, name="accept_invitation"), + path("staff/invitations/token//decline/", decline_invitation_view, name="decline_invitation"), # Stripe Webhooks (dj-stripe built-in handler) path("stripe/", include("djstripe.urls", namespace="djstripe")), # Public API v1 (for third-party integrations) @@ -82,6 +89,8 @@ urlpatterns += [ path("contracts/", include("contracts.urls")), # Communication Credits API path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")), + # Field Mobile API (for field employee mobile app) + path("mobile/", include("smoothschedule.field_mobile.urls", namespace="field_mobile")), # Tickets API path("tickets/", include("tickets.urls")), # Notifications API @@ -103,13 +112,6 @@ urlpatterns += [ # Hijack (masquerade) API path("auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"), path("auth/hijack/release/", hijack_release_view, name="hijack_release"), - # Staff Invitations API - path("staff/invitations/", staff_invitations_view, name="staff_invitations"), - path("staff/invitations//", cancel_invitation_view, name="cancel_invitation"), - path("staff/invitations//resend/", resend_invitation_view, name="resend_invitation"), - path("staff/invitations/token//", invitation_details_view, name="invitation_details"), - path("staff/invitations/token//accept/", accept_invitation_view, name="accept_invitation"), - path("staff/invitations/token//decline/", decline_invitation_view, name="decline_invitation"), # Business API path("business/current/", current_business_view, name="current_business"), path("business/current/update/", update_business_view, name="update_business"), diff --git a/smoothschedule/core/middleware.py b/smoothschedule/core/middleware.py index aa53cc1..c257aad 100644 --- a/smoothschedule/core/middleware.py +++ b/smoothschedule/core/middleware.py @@ -25,16 +25,27 @@ class TenantHeaderMiddleware(MiddlewareMixin): subdomain = request.META.get('HTTP_X_BUSINESS_SUBDOMAIN') if subdomain: Tenant = get_tenant_model() + tenant = None + + # First try by schema_name (for backwards compatibility) try: tenant = Tenant.objects.get(schema_name=subdomain) - # Only switch if different from current tenant (which might be 'public') - if request.tenant.schema_name != tenant.schema_name: - request.tenant = tenant - connection.set_tenant(request.tenant) - # sandbox_logger.debug(f"Switched to tenant '{subdomain}' via header") except Tenant.DoesNotExist: - # Invalid subdomain in header - ignore or could raise 400 - pass + # Try looking up by domain (subdomain matching) + from django_tenants.models import DomainMixin + from django.apps import apps + Domain = apps.get_model('tenants', 'Domain') + try: + # Look for domain that starts with the subdomain + domain_obj = Domain.objects.filter(domain__startswith=f"{subdomain}.").first() + if domain_obj: + tenant = domain_obj.tenant + except Exception: + pass + + if tenant and request.tenant.schema_name != tenant.schema_name: + request.tenant = tenant + connection.set_tenant(request.tenant) class SandboxModeMiddleware(MiddlewareMixin): diff --git a/smoothschedule/schedule/consumers.py b/smoothschedule/schedule/consumers.py new file mode 100644 index 0000000..1def075 --- /dev/null +++ b/smoothschedule/schedule/consumers.py @@ -0,0 +1,450 @@ +""" +WebSocket consumers for real-time calendar updates. + +Used by: +- Mobile app for field employees to get job updates +- Web frontend for real-time calendar sync +""" +import json +import logging +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from asgiref.sync import sync_to_async +from django.contrib.contenttypes.models import ContentType + +logger = logging.getLogger(__name__) + + +class CalendarConsumer(AsyncWebsocketConsumer): + """ + WebSocket consumer for real-time calendar/job updates. + + Groups: + - calendar_{tenant_schema}: All calendar updates for a tenant + - employee_jobs_{user_id}: Jobs assigned to a specific employee + - event_{event_id}: Updates for a specific event + + Message types: + - event_created: New event was created + - event_updated: Event details changed (time, status, etc.) + - event_deleted: Event was deleted + - job_assigned: Job was assigned to this employee + - job_unassigned: Job was unassigned from this employee + """ + + async def connect(self): + """Handle WebSocket connection.""" + user = self.scope.get("user") + + logger.info( + f"CalendarConsumer Connect: User={user}, " + f"Auth={user.is_authenticated if user else False}" + ) + + if not user or not user.is_authenticated: + logger.warning("CalendarConsumer: Rejecting unauthenticated connection") + await self.close() + return + + # Store user for later use + self.user = user + self.groups = [] + + # Add to user's personal job updates group + self.user_group = f"employee_jobs_{user.id}" + await self.channel_layer.group_add(self.user_group, self.channel_name) + self.groups.append(self.user_group) + + # Add to tenant group if user has a tenant + # Use sync_to_async for database access (tenant is a ForeignKey) + tenant = await self._get_user_tenant(user) + if tenant: + self.tenant_group = f"calendar_{tenant.schema_name}" + await self.channel_layer.group_add(self.tenant_group, self.channel_name) + self.groups.append(self.tenant_group) + logger.info(f"CalendarConsumer: User {user.id} joined tenant group {self.tenant_group}") + else: + self.tenant_group = None + + await self.accept() + logger.info(f"CalendarConsumer: Connection accepted for user {user.id}") + + # Send initial connection confirmation + await self.send(text_data=json.dumps({ + 'type': 'connection_established', + 'user_id': user.id, + 'groups': self.groups, + })) + + @database_sync_to_async + def _get_user_tenant(self, user): + """Get user's tenant in a sync context.""" + try: + # Force refresh from database to get tenant + user.refresh_from_db() + return user.tenant + except Exception: + return None + + async def disconnect(self, close_code): + """Handle WebSocket disconnection.""" + logger.info(f"CalendarConsumer: Disconnecting, code={close_code}") + + # Remove from all groups + for group in getattr(self, 'groups', []): + await self.channel_layer.group_discard(group, self.channel_name) + + async def receive(self, text_data): + """ + Handle incoming messages from client. + + Supported messages: + - subscribe_event: Subscribe to updates for a specific event + - unsubscribe_event: Unsubscribe from event updates + - ping: Keep-alive ping (responds with pong) + """ + try: + data = json.loads(text_data) + message_type = data.get('type') + + if message_type == 'subscribe_event': + event_id = data.get('event_id') + if event_id: + group_name = f"event_{event_id}" + await self.channel_layer.group_add(group_name, self.channel_name) + self.groups.append(group_name) + await self.send(text_data=json.dumps({ + 'type': 'subscribed', + 'event_id': event_id, + })) + + elif message_type == 'unsubscribe_event': + event_id = data.get('event_id') + if event_id: + group_name = f"event_{event_id}" + await self.channel_layer.group_discard(group_name, self.channel_name) + if group_name in self.groups: + self.groups.remove(group_name) + + elif message_type == 'ping': + await self.send(text_data=json.dumps({'type': 'pong'})) + + except json.JSONDecodeError: + logger.warning(f"CalendarConsumer: Invalid JSON received: {text_data}") + except Exception as e: + logger.error(f"CalendarConsumer: Error processing message: {e}") + + # ========================================================================= + # Event handlers - called when messages are sent to groups + # ========================================================================= + + async def event_created(self, event): + """Handle new event creation.""" + await self.send(text_data=json.dumps({ + 'type': 'event_created', + 'event': event.get('event'), + })) + + async def event_updated(self, event): + """Handle event update.""" + await self.send(text_data=json.dumps({ + 'type': 'event_updated', + 'event': event.get('event'), + 'changed_fields': event.get('changed_fields', []), + })) + + async def event_deleted(self, event): + """Handle event deletion.""" + await self.send(text_data=json.dumps({ + 'type': 'event_deleted', + 'event_id': event.get('event_id'), + })) + + async def event_status_changed(self, event): + """Handle status change (common for mobile app).""" + await self.send(text_data=json.dumps({ + 'type': 'event_status_changed', + 'event_id': event.get('event_id'), + 'old_status': event.get('old_status'), + 'new_status': event.get('new_status'), + 'event': event.get('event'), + })) + + async def job_assigned(self, event): + """Handle job assignment to employee.""" + await self.send(text_data=json.dumps({ + 'type': 'job_assigned', + 'event': event.get('event'), + })) + + async def job_unassigned(self, event): + """Handle job unassignment from employee.""" + await self.send(text_data=json.dumps({ + 'type': 'job_unassigned', + 'event_id': event.get('event_id'), + })) + + +# ============================================================================= +# Helper functions to broadcast updates +# ============================================================================= + +def get_event_broadcast_data(event): + """ + Serialize an Event for WebSocket broadcast. + + Returns a dict suitable for JSON serialization. + """ + data = { + 'id': event.id, + 'title': event.title, + 'start_time': event.start_time.isoformat() if event.start_time else None, + 'end_time': event.end_time.isoformat() if event.end_time else None, + 'status': event.status, + 'notes': event.notes, + 'created_at': event.created_at.isoformat() if event.created_at else None, + 'updated_at': event.updated_at.isoformat() if event.updated_at else None, + } + + # Add service info if available + if event.service: + data['service'] = { + 'id': event.service.id, + 'name': event.service.name, + 'duration': event.service.duration, + 'price': str(event.service.price), + } + + # Add pricing info + if event.deposit_amount: + data['deposit_amount'] = str(event.deposit_amount) + if event.final_price: + data['final_price'] = str(event.final_price) + + return data + + +def get_event_staff_user_ids(event): + """ + Get user IDs of staff assigned to an event. + + Returns list of user IDs for broadcasting. + """ + from schedule.models import Participant, Resource + from smoothschedule.users.models import User + + user_ids = set() + + try: + user_ct = ContentType.objects.get_for_model(User) + resource_ct = ContentType.objects.get_for_model(Resource) + + # Get direct user participants (staff role) + for participant in event.participants.filter( + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE], + content_type=user_ct + ): + if participant.object_id: + user_ids.add(participant.object_id) + + # Get users linked to resource participants + for participant in event.participants.filter( + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE], + content_type=resource_ct + ): + resource = participant.content_object + if resource and resource.user_id: + user_ids.add(resource.user_id) + + except Exception as e: + logger.error(f"Error getting staff user IDs for event {event.id}: {e}") + + return list(user_ids) + + +async def broadcast_event_update(event, update_type='event_updated', changed_fields=None, old_status=None): + """ + Broadcast an event update to all relevant WebSocket groups. + + Args: + event: The Event instance + update_type: One of 'event_created', 'event_updated', 'event_deleted', 'event_status_changed' + changed_fields: List of field names that changed (for event_updated) + old_status: Previous status (for event_status_changed) + """ + from channels.layers import get_channel_layer + from django.db import connection + + channel_layer = get_channel_layer() + if not channel_layer: + logger.warning("No channel layer configured, skipping WebSocket broadcast") + return + + event_data = await sync_to_async(get_event_broadcast_data)(event) + staff_user_ids = await sync_to_async(get_event_staff_user_ids)(event) + + # Get tenant schema for group name + tenant_schema = connection.schema_name if hasattr(connection, 'schema_name') else 'public' + + message = { + 'type': update_type, + 'event': event_data, + } + + if changed_fields: + message['changed_fields'] = changed_fields + + if old_status and update_type == 'event_status_changed': + message['old_status'] = old_status + message['new_status'] = event.status + message['event_id'] = event.id + + if update_type == 'event_deleted': + message['event_id'] = event.id + + # Broadcast to tenant group + if tenant_schema != 'public': + await channel_layer.group_send( + f"calendar_{tenant_schema}", + message + ) + + # Broadcast to individual employee groups + for user_id in staff_user_ids: + await channel_layer.group_send( + f"employee_jobs_{user_id}", + message + ) + + # Broadcast to event-specific group + await channel_layer.group_send( + f"event_{event.id}", + message + ) + + logger.info( + f"Broadcast {update_type} for event {event.id} to " + f"tenant={tenant_schema}, staff={staff_user_ids}" + ) + + +class ResourceLocationConsumer(AsyncWebsocketConsumer): + """ + WebSocket consumer for real-time resource location tracking. + + Used by web dashboard to show live staff location on map while en route. + + Groups: + - resource_location_{resource_id}: Location updates for a specific resource + """ + + async def connect(self): + """Handle WebSocket connection.""" + user = self.scope.get("user") + self.resource_id = self.scope['url_route']['kwargs'].get('resource_id') + + logger.info( + f"ResourceLocationConsumer Connect: User={user}, Resource={self.resource_id}, " + f"Auth={user.is_authenticated if user else False}" + ) + + if not user or not user.is_authenticated: + logger.warning("ResourceLocationConsumer: Rejecting unauthenticated connection") + await self.close() + return + + if not self.resource_id: + logger.warning("ResourceLocationConsumer: No resource_id provided") + await self.close() + return + + # Store user for later use + self.user = user + self.group_name = f"resource_location_{self.resource_id}" + + # Add to resource location group + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + logger.info(f"ResourceLocationConsumer: Connection accepted for resource {self.resource_id}") + + # Send initial connection confirmation + await self.send(text_data=json.dumps({ + 'type': 'connection_established', + 'resource_id': self.resource_id, + })) + + async def disconnect(self, close_code): + """Handle WebSocket disconnection.""" + logger.info(f"ResourceLocationConsumer: Disconnecting resource {getattr(self, 'resource_id', 'unknown')}, code={close_code}") + + if hasattr(self, 'group_name'): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data): + """Handle incoming messages from client (ping/pong only).""" + try: + data = json.loads(text_data) + message_type = data.get('type') + + if message_type == 'ping': + await self.send(text_data=json.dumps({'type': 'pong'})) + + except json.JSONDecodeError: + logger.warning(f"ResourceLocationConsumer: Invalid JSON received: {text_data}") + + async def location_update(self, event): + """Handle location update broadcast.""" + await self.send(text_data=json.dumps({ + 'type': 'location_update', + 'latitude': event.get('latitude'), + 'longitude': event.get('longitude'), + 'accuracy': event.get('accuracy'), + 'heading': event.get('heading'), + 'speed': event.get('speed'), + 'timestamp': event.get('timestamp'), + 'active_job': event.get('active_job'), + })) + + async def tracking_stopped(self, event): + """Handle tracking stopped notification.""" + await self.send(text_data=json.dumps({ + 'type': 'tracking_stopped', + 'resource_id': event.get('resource_id'), + 'reason': event.get('reason'), + })) + + +async def broadcast_resource_location_update(resource_id, location_data, active_job=None): + """ + Broadcast a location update to all connected clients watching this resource. + + Args: + resource_id: The resource ID to broadcast to + location_data: Dict with latitude, longitude, accuracy, heading, speed, timestamp + active_job: Optional dict with id, title, status, status_display + """ + from channels.layers import get_channel_layer + + channel_layer = get_channel_layer() + if not channel_layer: + logger.warning("No channel layer configured, skipping location broadcast") + return + + message = { + 'type': 'location_update', + 'latitude': location_data.get('latitude'), + 'longitude': location_data.get('longitude'), + 'accuracy': location_data.get('accuracy'), + 'heading': location_data.get('heading'), + 'speed': location_data.get('speed'), + 'timestamp': location_data.get('timestamp'), + 'active_job': active_job, + } + + await channel_layer.group_send( + f"resource_location_{resource_id}", + message + ) + + logger.debug(f"Broadcast location update for resource {resource_id}") diff --git a/smoothschedule/schedule/migrations/0029_add_user_can_edit_schedule.py b/smoothschedule/schedule/migrations/0029_add_user_can_edit_schedule.py new file mode 100644 index 0000000..f5c4cb8 --- /dev/null +++ b/smoothschedule/schedule/migrations/0029_add_user_can_edit_schedule.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-12-07 02:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedule', '0028_add_timeblock_and_holiday'), + ] + + operations = [ + migrations.AddField( + model_name='resource', + name='user_can_edit_schedule', + field=models.BooleanField(default=False, help_text="Allow the resource's linked user to edit their own schedule (reschedule/resize) regardless of role"), + ), + migrations.AlterField( + model_name='event', + name='status', + field=models.CharField(choices=[('SCHEDULED', 'Scheduled'), ('EN_ROUTE', 'En Route'), ('IN_PROGRESS', 'In Progress'), ('CANCELED', 'Canceled'), ('COMPLETED', 'Completed'), ('AWAITING_PAYMENT', 'Awaiting Payment'), ('PAID', 'Paid'), ('NOSHOW', 'No Show')], db_index=True, default='SCHEDULED', max_length=20), + ), + ] diff --git a/smoothschedule/schedule/models.py b/smoothschedule/schedule/models.py index 804bdd0..6a4b8a6 100644 --- a/smoothschedule/schedule/models.py +++ b/smoothschedule/schedule/models.py @@ -230,6 +230,12 @@ class Resource(models.Model): help_text="When this resource was archived due to quota overage" ) + # Permission for linked user to edit their own schedule + user_can_edit_schedule = models.BooleanField( + default=False, + help_text="Allow the resource's linked user to edit their own schedule (reschedule/resize) regardless of role" + ) + class Meta: ordering = ['name'] indexes = [models.Index(fields=['is_active', 'name'])] @@ -246,6 +252,8 @@ class Event(models.Model): """ class Status(models.TextChoices): SCHEDULED = 'SCHEDULED', 'Scheduled' + EN_ROUTE = 'EN_ROUTE', 'En Route' # Employee is traveling to job site + IN_PROGRESS = 'IN_PROGRESS', 'In Progress' # Employee is working on job CANCELED = 'CANCELED', 'Canceled' COMPLETED = 'COMPLETED', 'Completed' AWAITING_PAYMENT = 'AWAITING_PAYMENT', 'Awaiting Payment' # Service done, waiting for final charge diff --git a/smoothschedule/schedule/routing.py b/smoothschedule/schedule/routing.py new file mode 100644 index 0000000..2b67677 --- /dev/null +++ b/smoothschedule/schedule/routing.py @@ -0,0 +1,13 @@ +""" +WebSocket URL routing for the schedule app. +""" +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + # Calendar updates for web and mobile apps + re_path(r"^/?ws/calendar/?$", consumers.CalendarConsumer.as_asgi()), + # Resource location tracking for web dashboard + re_path(r"^/?ws/resource-location/(?P\d+)/?$", consumers.ResourceLocationConsumer.as_asgi()), +] diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index 9af695a..6918331 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -203,7 +203,7 @@ class ResourceSerializer(serializers.ModelSerializer): 'description', 'max_concurrent_events', 'buffer_duration', 'is_active', 'capacity_description', 'saved_lane_count', 'created_at', 'updated_at', - 'is_archived_by_quota', + 'is_archived_by_quota', 'user_can_edit_schedule', ] read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota'] diff --git a/smoothschedule/schedule/signals.py b/smoothschedule/schedule/signals.py index c7c5b0b..cac3be6 100644 --- a/smoothschedule/schedule/signals.py +++ b/smoothschedule/schedule/signals.py @@ -6,6 +6,7 @@ Handles: 2. Rescheduling Celery tasks when events are modified (time/duration changes) 3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified 4. Cancelling tasks when Events are deleted or cancelled +5. Broadcasting real-time updates via WebSocket for calendar sync """ import logging from django.db.models.signals import post_save, pre_save, post_delete, pre_delete @@ -14,6 +15,36 @@ from django.dispatch import receiver logger = logging.getLogger(__name__) +# ============================================================================ +# WebSocket Broadcasting Helpers +# ============================================================================ + +def broadcast_event_change_sync(event, update_type, changed_fields=None, old_status=None): + """ + Synchronous wrapper to broadcast event changes via WebSocket. + + Uses async_to_sync to call the async broadcast function from signals. + """ + from asgiref.sync import async_to_sync + from channels.layers import get_channel_layer + + channel_layer = get_channel_layer() + if not channel_layer: + logger.debug("No channel layer configured, skipping WebSocket broadcast") + return + + try: + from .consumers import broadcast_event_update + async_to_sync(broadcast_event_update)( + event, + update_type=update_type, + changed_fields=changed_fields, + old_status=old_status + ) + except Exception as e: + logger.error(f"Failed to broadcast event change: {e}") + + @receiver(post_save, sender='schedule.Event') def auto_attach_global_plugins(sender, instance, created, **kwargs): """ @@ -313,3 +344,61 @@ def cancel_event_tasks_on_cancel(sender, instance, created, **kwargs): from .tasks import cancel_event_tasks logger.info(f"Event '{instance}' was cancelled, cancelling all plugin tasks") cancel_event_tasks(instance.id) + + +# ============================================================================ +# WebSocket Broadcasting Signals +# ============================================================================ + +@receiver(post_save, sender='schedule.Event') +def broadcast_event_save(sender, instance, created, **kwargs): + """ + Broadcast event creation/update via WebSocket for real-time calendar sync. + """ + old_status = getattr(instance, '_old_status', None) + old_start = getattr(instance, '_old_start_time', None) + old_end = getattr(instance, '_old_end_time', None) + + if created: + # New event created + broadcast_event_change_sync(instance, 'event_created') + logger.info(f"Broadcast event_created for event {instance.id}") + + elif old_status and old_status != instance.status: + # Status changed + broadcast_event_change_sync( + instance, + 'event_status_changed', + old_status=old_status + ) + logger.info( + f"Broadcast event_status_changed for event {instance.id}: " + f"{old_status} -> {instance.status}" + ) + + else: + # Other update - determine what changed + changed_fields = [] + + if old_start and old_start != instance.start_time: + changed_fields.append('start_time') + if old_end and old_end != instance.end_time: + changed_fields.append('end_time') + + # Always broadcast updates for changes + broadcast_event_change_sync( + instance, + 'event_updated', + changed_fields=changed_fields if changed_fields else None + ) + logger.info(f"Broadcast event_updated for event {instance.id}") + + +@receiver(pre_delete, sender='schedule.Event') +def broadcast_event_delete(sender, instance, **kwargs): + """ + Broadcast event deletion via WebSocket. + """ + # Store the event data before deletion for broadcasting + broadcast_event_change_sync(instance, 'event_deleted') + logger.info(f"Broadcast event_deleted for event {instance.id}") diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py index 7470105..b6184b3 100644 --- a/smoothschedule/schedule/views.py +++ b/smoothschedule/schedule/views.py @@ -72,6 +72,7 @@ class ResourceViewSet(viewsets.ModelViewSet): Permissions: - Must be authenticated + - Staff members cannot access resources (owners/managers only) - Subject to MAX_RESOURCES quota (hard block on creation) The HasQuota permission prevents creating resources when tenant @@ -91,6 +92,7 @@ class ResourceViewSet(viewsets.ModelViewSet): Return resources for the current tenant. CRITICAL: Validates user belongs to the current tenant. + Staff members are denied access to resources. """ queryset = Resource.objects.all() @@ -98,6 +100,10 @@ class ResourceViewSet(viewsets.ModelViewSet): if not user.is_authenticated: return queryset.none() + # Staff members cannot access resources + if user.role == User.Role.TENANT_STAFF: + return queryset.none() + # Validate user belongs to the current tenant request_tenant = getattr(self.request, 'tenant', None) if user.tenant and request_tenant: @@ -106,6 +112,32 @@ class ResourceViewSet(viewsets.ModelViewSet): return queryset + def _check_staff_permission(self): + """Deny access for staff members.""" + from rest_framework.exceptions import PermissionDenied + if self.request.user.role == User.Role.TENANT_STAFF: + raise PermissionDenied("Staff members do not have access to resources.") + + def create(self, request, *args, **kwargs): + """Create resource - deny staff access.""" + self._check_staff_permission() + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + """Update resource - deny staff access.""" + self._check_staff_permission() + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Partial update resource - deny staff access.""" + self._check_staff_permission() + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Delete resource - deny staff access.""" + self._check_staff_permission() + return super().destroy(request, *args, **kwargs) + def perform_create(self, serializer): """Create resource (quota-checked by HasQuota permission)""" serializer.save() @@ -114,6 +146,108 @@ class ResourceViewSet(viewsets.ModelViewSet): """Update resource""" serializer.save() + @action(detail=True, methods=['get']) + def location(self, request, pk=None): + """ + Get the latest location for a resource's linked staff member. + + GET /api/resources/{id}/location/ + + Returns the most recent location update for the user linked to this resource, + along with their current job status (if any). + """ + from smoothschedule.field_mobile.models import EmployeeLocationUpdate + from django.contrib.contenttypes.models import ContentType + + resource = self.get_object() + + # Check if resource has a linked user + if not resource.user: + return Response({ + 'has_location': False, + 'message': 'Resource has no linked user' + }) + + user = resource.user + tenant = getattr(request, 'tenant', None) + + if not tenant: + return Response({ + 'has_location': False, + 'message': 'No tenant context' + }) + + # Get the latest location for this user + latest_location = EmployeeLocationUpdate.objects.filter( + tenant=tenant, + employee=user + ).order_by('-timestamp').first() + + if not latest_location: + return Response({ + 'has_location': False, + 'message': 'No location data available' + }) + + # Get the current active job for this user (EN_ROUTE or IN_PROGRESS) + from django.db.models import Q + user_ct = ContentType.objects.get_for_model(user.__class__) + resource_ct = ContentType.objects.get_for_model(Resource) + + # Get resource IDs linked to this user + user_resource_ids = list( + Resource.objects.filter(user=user).values_list('id', flat=True) + ) + + # Find active events + from schedule.models import Event, Participant + active_statuses = ['EN_ROUTE', 'IN_PROGRESS'] + + # Events where user is a participant + user_participant_events = Participant.objects.filter( + content_type=user_ct, + object_id=user.id + ).values_list('event_id', flat=True) + + # Events where user's resource is a participant + resource_participant_events = Participant.objects.filter( + content_type=resource_ct, + object_id__in=user_resource_ids + ).values_list('event_id', flat=True) if user_resource_ids else [] + + all_event_ids = set(user_participant_events) | set(resource_participant_events) + + active_job = Event.objects.filter( + id__in=all_event_ids, + status__in=active_statuses + ).order_by('-start_time').first() + + # Check if this location is recent (within last 10 minutes) and for an active job + from django.utils import timezone + from datetime import timedelta + + is_tracking = False + if active_job and latest_location.event_id == active_job.id: + time_since_update = timezone.now() - latest_location.timestamp + is_tracking = time_since_update < timedelta(minutes=10) + + return Response({ + 'has_location': True, + 'latitude': float(latest_location.latitude), + 'longitude': float(latest_location.longitude), + 'accuracy': latest_location.accuracy, + 'heading': latest_location.heading, + 'speed': latest_location.speed, + 'timestamp': latest_location.timestamp.isoformat(), + 'is_tracking': is_tracking, + 'active_job': { + 'id': active_job.id, + 'title': active_job.title, + 'status': active_job.status, + 'status_display': active_job.get_status_display(), + } if active_job else None, + }) + class EventViewSet(viewsets.ModelViewSet): """ @@ -257,6 +391,10 @@ class CustomerViewSet(viewsets.ModelViewSet): API endpoint for managing Customers. Customers are Users with role=CUSTOMER belonging to the current tenant. + + Permissions: + - Staff members cannot list customers + - Staff can only retrieve individual customers with limited fields (name, address) """ serializer_class = CustomerSerializer permission_classes = [IsAuthenticated] @@ -266,6 +404,56 @@ class CustomerViewSet(viewsets.ModelViewSet): ordering_fields = ['email', 'created_at'] ordering = ['email'] + def _check_staff_permission_for_list(self): + """Deny list access for staff members.""" + from rest_framework.exceptions import PermissionDenied + if self.request.user.role == User.Role.TENANT_STAFF: + raise PermissionDenied("Staff members do not have access to customer list.") + + def list(self, request, *args, **kwargs): + """List customers - deny staff access.""" + self._check_staff_permission_for_list() + return super().list(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + """Create customer - deny staff access.""" + self._check_staff_permission_for_list() + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + """Update customer - deny staff access.""" + self._check_staff_permission_for_list() + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Partial update customer - deny staff access.""" + self._check_staff_permission_for_list() + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Delete customer - deny staff access.""" + self._check_staff_permission_for_list() + return super().destroy(request, *args, **kwargs) + + def retrieve(self, request, *args, **kwargs): + """ + Retrieve customer - staff get limited fields only. + """ + instance = self.get_object() + if request.user.role == User.Role.TENANT_STAFF: + # Return only name and address for staff + return Response({ + 'id': instance.id, + 'name': instance.full_name, + 'first_name': instance.first_name, + 'last_name': instance.last_name, + 'city': getattr(instance, 'city', ''), + 'state': getattr(instance, 'state', ''), + 'zip': getattr(instance, 'zip', ''), + }) + serializer = self.get_serializer(instance) + return Response(serializer.data) + def get_queryset(self): """ Return customers for the current tenant, filtered by sandbox mode. @@ -343,6 +531,7 @@ class ServiceViewSet(viewsets.ModelViewSet): Permissions: - Must be authenticated + - Staff members cannot access services - Subject to MAX_SERVICES quota (hard block on creation) """ queryset = Service.objects.filter(is_active=True) @@ -354,6 +543,42 @@ class ServiceViewSet(viewsets.ModelViewSet): ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at'] ordering = ['display_order', 'name'] + def _check_staff_permission(self): + """Deny access for staff members.""" + from rest_framework.exceptions import PermissionDenied + if self.request.user.role == User.Role.TENANT_STAFF: + raise PermissionDenied("Staff members do not have access to services.") + + def list(self, request, *args, **kwargs): + """List services - deny staff access.""" + self._check_staff_permission() + return super().list(request, *args, **kwargs) + + def retrieve(self, request, *args, **kwargs): + """Retrieve service - deny staff access.""" + self._check_staff_permission() + return super().retrieve(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + """Create service - deny staff access.""" + self._check_staff_permission() + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + """Update service - deny staff access.""" + self._check_staff_permission() + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Partial update service - deny staff access.""" + self._check_staff_permission() + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Delete service - deny staff access.""" + self._check_staff_permission() + return super().destroy(request, *args, **kwargs) + def get_queryset(self): """Return services for the current tenant, optionally including inactive ones.""" queryset = Service.objects.all() @@ -362,6 +587,10 @@ class ServiceViewSet(viewsets.ModelViewSet): if not user.is_authenticated: return queryset.none() + # Staff members cannot access services + if user.role == User.Role.TENANT_STAFF: + return queryset.none() + # CRITICAL: Validate user belongs to the current request tenant request_tenant = getattr(self.request, 'tenant', None) if user.tenant and request_tenant: @@ -384,6 +613,7 @@ class ServiceViewSet(viewsets.ModelViewSet): Expects: { "order": [1, 3, 2, 5, 4] } Where the list contains service IDs in the desired display order. """ + self._check_staff_permission() order = request.data.get('order', []) if not isinstance(order, list): @@ -560,6 +790,11 @@ class ScheduledTaskViewSet(viewsets.ModelViewSet): """Check if tenant has permission to access scheduled tasks.""" from rest_framework.exceptions import PermissionDenied + # Staff members cannot access scheduled tasks + user = self.request.user + if user.is_authenticated and user.role == User.Role.TENANT_STAFF: + raise PermissionDenied("Staff members do not have access to scheduled tasks.") + tenant = getattr(self.request, 'tenant', None) if tenant: if not tenant.has_feature('can_use_plugins'): diff --git a/smoothschedule/smoothschedule/field_mobile/__init__.py b/smoothschedule/smoothschedule/field_mobile/__init__.py new file mode 100644 index 0000000..ba28bbe --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/__init__.py @@ -0,0 +1 @@ +# Field Mobile App - Backend API for field employee mobile app diff --git a/smoothschedule/smoothschedule/field_mobile/apps.py b/smoothschedule/smoothschedule/field_mobile/apps.py new file mode 100644 index 0000000..7ad34df --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class FieldMobileConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'smoothschedule.field_mobile' + label = 'field_mobile' + verbose_name = 'Field Mobile App' + + def ready(self): + # Import signals if needed in future + pass diff --git a/smoothschedule/smoothschedule/field_mobile/migrations/0001_initial.py b/smoothschedule/smoothschedule/field_mobile/migrations/0001_initial.py new file mode 100644 index 0000000..a98331f --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2.8 on 2025-12-06 20:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('comms_credits', '0002_add_stripe_customer_id'), + ('core', '0022_add_can_use_tasks'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EmployeeLocationUpdate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_id', models.IntegerField(db_index=True, help_text='ID of the active job in tenant schema')), + ('latitude', models.DecimalField(decimal_places=7, help_text='GPS latitude', max_digits=10)), + ('longitude', models.DecimalField(decimal_places=7, help_text='GPS longitude', max_digits=10)), + ('accuracy', models.FloatField(blank=True, help_text='GPS accuracy in meters', null=True)), + ('altitude', models.FloatField(blank=True, help_text='Altitude in meters (if available)', null=True)), + ('heading', models.FloatField(blank=True, help_text='Direction of travel in degrees (0-360)', null=True)), + ('speed', models.FloatField(blank=True, help_text='Speed in meters per second', null=True)), + ('timestamp', models.DateTimeField(db_index=True, help_text='When the location was captured on device')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='When the server received this update')), + ('battery_level', models.FloatField(blank=True, help_text='Device battery level (0.0-1.0)', null=True)), + ('employee', models.ForeignKey(help_text='Employee being tracked', on_delete=django.db.models.deletion.CASCADE, related_name='location_updates', to=settings.AUTH_USER_MODEL)), + ('tenant', models.ForeignKey(help_text='Tenant this location update belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='employee_locations', to='core.tenant')), + ], + options={ + 'verbose_name': 'Employee Location Update', + 'verbose_name_plural': 'Employee Location Updates', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['tenant', 'event_id', '-timestamp'], name='field_mobil_tenant__1284a1_idx'), models.Index(fields=['employee', '-timestamp'], name='field_mobil_employe_7b4ee0_idx'), models.Index(fields=['tenant', 'employee', 'event_id'], name='field_mobil_tenant__f49e41_idx')], + }, + ), + migrations.CreateModel( + name='EventStatusHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_id', models.IntegerField(db_index=True, help_text='ID of the Event in tenant schema')), + ('old_status', models.CharField(help_text='Previous status before change', max_length=20)), + ('new_status', models.CharField(help_text='New status after change', max_length=20)), + ('changed_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('notes', models.TextField(blank=True, help_text='Optional notes about the status change')), + ('latitude', models.DecimalField(blank=True, decimal_places=7, help_text='Latitude where status was changed', max_digits=10, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=7, help_text='Longitude where status was changed', max_digits=10, null=True)), + ('source', models.CharField(choices=[('mobile_app', 'Mobile App'), ('web_app', 'Web App'), ('api', 'API'), ('system', 'System')], default='mobile_app', help_text='Where the status change originated', max_length=20)), + ('changed_by', models.ForeignKey(help_text='User who made the status change', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to=settings.AUTH_USER_MODEL)), + ('tenant', models.ForeignKey(help_text='Tenant this status change belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='event_status_history', to='core.tenant')), + ], + options={ + 'verbose_name': 'Event Status History', + 'verbose_name_plural': 'Event Status Histories', + 'ordering': ['-changed_at'], + 'indexes': [models.Index(fields=['tenant', 'event_id'], name='field_mobil_tenant__746f7d_idx'), models.Index(fields=['changed_by', '-changed_at'], name='field_mobil_changed_18d75d_idx')], + }, + ), + migrations.CreateModel( + name='FieldCallLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_id', models.IntegerField(db_index=True, help_text='ID of the job this call is associated with')), + ('call_type', models.CharField(choices=[('voice', 'Voice Call'), ('sms', 'SMS')], max_length=10)), + ('direction', models.CharField(choices=[('outbound', 'Employee to Customer'), ('inbound', 'Customer to Employee')], max_length=10)), + ('status', models.CharField(choices=[('initiated', 'Initiated'), ('ringing', 'Ringing'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('busy', 'Busy'), ('no_answer', 'No Answer'), ('failed', 'Failed'), ('canceled', 'Canceled')], default='initiated', max_length=20)), + ('customer_phone', models.CharField(help_text="Customer's phone number (E.164 format)", max_length=20)), + ('proxy_number', models.CharField(help_text='Twilio proxy number used', max_length=20)), + ('twilio_call_sid', models.CharField(blank=True, help_text='Twilio Call SID for voice calls', max_length=50)), + ('twilio_message_sid', models.CharField(blank=True, help_text='Twilio Message SID for SMS', max_length=50)), + ('duration_seconds', models.IntegerField(blank=True, help_text='Call duration in seconds (for voice)', null=True)), + ('cost_cents', models.IntegerField(default=0, help_text='Cost charged to tenant in cents')), + ('initiated_at', models.DateTimeField(auto_now_add=True)), + ('answered_at', models.DateTimeField(blank=True, null=True)), + ('ended_at', models.DateTimeField(blank=True, null=True)), + ('employee', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='field_calls', to=settings.AUTH_USER_MODEL)), + ('masked_session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='field_calls', to='comms_credits.maskedsession')), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_call_logs', to='core.tenant')), + ], + options={ + 'verbose_name': 'Field Call Log', + 'verbose_name_plural': 'Field Call Logs', + 'ordering': ['-initiated_at'], + 'indexes': [models.Index(fields=['tenant', 'event_id'], name='field_mobil_tenant__8235dc_idx'), models.Index(fields=['employee', '-initiated_at'], name='field_mobil_employe_2a932d_idx'), models.Index(fields=['twilio_call_sid'], name='field_mobil_twilio__4c77e8_idx')], + }, + ), + ] diff --git a/smoothschedule/smoothschedule/field_mobile/migrations/__init__.py b/smoothschedule/smoothschedule/field_mobile/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/field_mobile/models.py b/smoothschedule/smoothschedule/field_mobile/models.py new file mode 100644 index 0000000..7ce6d0e --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/models.py @@ -0,0 +1,329 @@ +""" +Field Mobile Models + +Models for tracking field employee activity: +- EventStatusHistory: Audit log for job status changes +- EmployeeLocationUpdate: GPS tracking during en-route/in-progress +""" +from django.db import models +from django.utils import timezone + + +class EventStatusHistory(models.Model): + """ + Audit log for event/job status changes. + + Records who changed the status, when, and from what location. + This model lives in the public schema and references events by ID + since events are in tenant schemas. + """ + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='event_status_history', + help_text="Tenant this status change belongs to" + ) + event_id = models.IntegerField( + db_index=True, + help_text="ID of the Event in tenant schema" + ) + old_status = models.CharField( + max_length=20, + help_text="Previous status before change" + ) + new_status = models.CharField( + max_length=20, + help_text="New status after change" + ) + changed_by = models.ForeignKey( + 'users.User', + on_delete=models.SET_NULL, + null=True, + related_name='status_changes', + help_text="User who made the status change" + ) + changed_at = models.DateTimeField( + auto_now_add=True, + db_index=True + ) + notes = models.TextField( + blank=True, + help_text="Optional notes about the status change" + ) + + # Location at time of status change (optional) + latitude = models.DecimalField( + max_digits=10, + decimal_places=7, + null=True, + blank=True, + help_text="Latitude where status was changed" + ) + longitude = models.DecimalField( + max_digits=10, + decimal_places=7, + null=True, + blank=True, + help_text="Longitude where status was changed" + ) + + # Source of the change + source = models.CharField( + max_length=20, + default='mobile_app', + choices=[ + ('mobile_app', 'Mobile App'), + ('web_app', 'Web App'), + ('api', 'API'), + ('system', 'System'), + ], + help_text="Where the status change originated" + ) + + class Meta: + ordering = ['-changed_at'] + indexes = [ + models.Index(fields=['tenant', 'event_id']), + models.Index(fields=['changed_by', '-changed_at']), + ] + verbose_name = 'Event Status History' + verbose_name_plural = 'Event Status Histories' + + def __str__(self): + return f"Event {self.event_id}: {self.old_status} → {self.new_status}" + + +class EmployeeLocationUpdate(models.Model): + """ + Periodic location updates from field employees. + + Stored while an employee is en-route to or working on a job. + Location tracking automatically stops when job is completed. + + Privacy considerations: + - Only tracked during active jobs (EN_ROUTE, IN_PROGRESS) + - Automatically stops when job status changes to COMPLETED/CANCELED + - Old location data can be purged after a configurable retention period + """ + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='employee_locations', + help_text="Tenant this location update belongs to" + ) + employee = models.ForeignKey( + 'users.User', + on_delete=models.CASCADE, + related_name='location_updates', + help_text="Employee being tracked" + ) + event_id = models.IntegerField( + db_index=True, + help_text="ID of the active job in tenant schema" + ) + + # Location data + latitude = models.DecimalField( + max_digits=10, + decimal_places=7, + help_text="GPS latitude" + ) + longitude = models.DecimalField( + max_digits=10, + decimal_places=7, + help_text="GPS longitude" + ) + accuracy = models.FloatField( + null=True, + blank=True, + help_text="GPS accuracy in meters" + ) + altitude = models.FloatField( + null=True, + blank=True, + help_text="Altitude in meters (if available)" + ) + heading = models.FloatField( + null=True, + blank=True, + help_text="Direction of travel in degrees (0-360)" + ) + speed = models.FloatField( + null=True, + blank=True, + help_text="Speed in meters per second" + ) + + # Timestamp from device + timestamp = models.DateTimeField( + db_index=True, + help_text="When the location was captured on device" + ) + + # Server timestamp + created_at = models.DateTimeField( + auto_now_add=True, + help_text="When the server received this update" + ) + + # Battery level (useful for understanding tracking reliability) + battery_level = models.FloatField( + null=True, + blank=True, + help_text="Device battery level (0.0-1.0)" + ) + + class Meta: + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['tenant', 'event_id', '-timestamp']), + models.Index(fields=['employee', '-timestamp']), + models.Index(fields=['tenant', 'employee', 'event_id']), + ] + verbose_name = 'Employee Location Update' + verbose_name_plural = 'Employee Location Updates' + + def __str__(self): + return f"{self.employee} @ ({self.latitude}, {self.longitude})" + + @classmethod + def get_latest_for_event(cls, tenant_id, event_id): + """Get the most recent location update for an event.""" + return cls.objects.filter( + tenant_id=tenant_id, + event_id=event_id + ).order_by('-timestamp').first() + + @classmethod + def get_route_for_event(cls, tenant_id, event_id, limit=100): + """ + Get location history for an event (for drawing route on map). + + Returns locations ordered by timestamp ascending (oldest first). + """ + return list( + cls.objects.filter( + tenant_id=tenant_id, + event_id=event_id + ).order_by('timestamp')[:limit].values( + 'latitude', 'longitude', 'timestamp', 'accuracy' + ) + ) + + +class FieldCallLog(models.Model): + """ + Log of masked calls and SMS between employees and customers. + + Tracks all communication through the proxy number for billing + and audit purposes. + """ + class CallType(models.TextChoices): + VOICE = 'voice', 'Voice Call' + SMS = 'sms', 'SMS' + + class Direction(models.TextChoices): + OUTBOUND = 'outbound', 'Employee to Customer' + INBOUND = 'inbound', 'Customer to Employee' + + class Status(models.TextChoices): + INITIATED = 'initiated', 'Initiated' + RINGING = 'ringing', 'Ringing' + IN_PROGRESS = 'in_progress', 'In Progress' + COMPLETED = 'completed', 'Completed' + BUSY = 'busy', 'Busy' + NO_ANSWER = 'no_answer', 'No Answer' + FAILED = 'failed', 'Failed' + CANCELED = 'canceled', 'Canceled' + + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + related_name='field_call_logs' + ) + event_id = models.IntegerField( + db_index=True, + help_text="ID of the job this call is associated with" + ) + + # Call metadata + call_type = models.CharField( + max_length=10, + choices=CallType.choices + ) + direction = models.CharField( + max_length=10, + choices=Direction.choices + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.INITIATED + ) + + # Participants + employee = models.ForeignKey( + 'users.User', + on_delete=models.SET_NULL, + null=True, + related_name='field_calls' + ) + customer_phone = models.CharField( + max_length=20, + help_text="Customer's phone number (E.164 format)" + ) + proxy_number = models.CharField( + max_length=20, + help_text="Twilio proxy number used" + ) + + # Twilio references + twilio_call_sid = models.CharField( + max_length=50, + blank=True, + help_text="Twilio Call SID for voice calls" + ) + twilio_message_sid = models.CharField( + max_length=50, + blank=True, + help_text="Twilio Message SID for SMS" + ) + + # Masked session reference + masked_session = models.ForeignKey( + 'comms_credits.MaskedSession', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='field_calls' + ) + + # Duration and cost + duration_seconds = models.IntegerField( + null=True, + blank=True, + help_text="Call duration in seconds (for voice)" + ) + cost_cents = models.IntegerField( + default=0, + help_text="Cost charged to tenant in cents" + ) + + # Timestamps + initiated_at = models.DateTimeField(auto_now_add=True) + answered_at = models.DateTimeField(null=True, blank=True) + ended_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-initiated_at'] + indexes = [ + models.Index(fields=['tenant', 'event_id']), + models.Index(fields=['employee', '-initiated_at']), + models.Index(fields=['twilio_call_sid']), + ] + verbose_name = 'Field Call Log' + verbose_name_plural = 'Field Call Logs' + + def __str__(self): + direction = "→" if self.direction == self.Direction.OUTBOUND else "←" + return f"{self.call_type}: Employee {direction} Customer ({self.status})" diff --git a/smoothschedule/smoothschedule/field_mobile/serializers.py b/smoothschedule/smoothschedule/field_mobile/serializers.py new file mode 100644 index 0000000..c5fa534 --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/serializers.py @@ -0,0 +1,514 @@ +""" +Field Mobile Serializers + +Serializers for the field employee mobile app API. +""" +from rest_framework import serializers +from django.utils import timezone + +from schedule.models import Event, Service, Participant +from smoothschedule.field_mobile.models import ( + EventStatusHistory, + EmployeeLocationUpdate, + FieldCallLog, +) + + +class ServiceSummarySerializer(serializers.ModelSerializer): + """Minimal service info for job cards.""" + + class Meta: + model = Service + fields = ['id', 'name', 'duration', 'price'] + + +class CustomerInfoSerializer(serializers.Serializer): + """ + Customer information for a job. + + Phone number is masked for privacy - actual calls go through proxy. + """ + id = serializers.IntegerField() + name = serializers.SerializerMethodField() + phone_masked = serializers.SerializerMethodField() + email = serializers.CharField(allow_null=True) + + def get_name(self, obj): + """Return customer's full name.""" + if hasattr(obj, 'full_name') and obj.full_name: + return obj.full_name + if hasattr(obj, 'get_full_name'): + return obj.get_full_name() or getattr(obj, 'username', 'Customer') + return getattr(obj, 'username', 'Customer') + + def get_phone_masked(self, obj): + """Return masked phone number (last 4 digits only).""" + phone = getattr(obj, 'phone', None) + if phone and len(phone) >= 4: + return f"***-***-{phone[-4:]}" + return None + + +class JobListSerializer(serializers.ModelSerializer): + """ + Serializer for job list (today's and upcoming jobs). + + Optimized for quick loading on mobile. + """ + service_name = serializers.SerializerMethodField() + customer_name = serializers.SerializerMethodField() + address = serializers.SerializerMethodField() + status_display = serializers.CharField(source='get_status_display', read_only=True) + duration_minutes = serializers.SerializerMethodField() + allowed_transitions = serializers.SerializerMethodField() + + class Meta: + model = Event + fields = [ + 'id', + 'title', + 'start_time', + 'end_time', + 'status', + 'status_display', + 'service_name', + 'customer_name', + 'address', + 'duration_minutes', + 'allowed_transitions', + ] + + def get_service_name(self, obj): + return obj.service.name if obj.service else None + + def get_customer_name(self, obj): + """Get the customer's name from participants.""" + customer = self._get_customer_participant(obj) + if customer: + return getattr(customer, 'full_name', None) or getattr(customer, 'username', 'Customer') + return None + + def get_address(self, obj): + """Get customer's address if available.""" + # First check event notes for address + if obj.notes and 'address' in obj.notes.lower(): + return obj.notes + + # Try to get from customer + customer = self._get_customer_participant(obj) + if customer and hasattr(customer, 'address'): + return customer.address + + return None + + def get_duration_minutes(self, obj): + """Calculate event duration in minutes.""" + if obj.start_time and obj.end_time: + delta = obj.end_time - obj.start_time + return int(delta.total_seconds() / 60) + return None + + def get_allowed_transitions(self, obj): + """Get list of statuses this job can transition to.""" + from smoothschedule.field_mobile.services import StatusMachine + + # Get the valid transitions without needing user context + return StatusMachine.VALID_TRANSITIONS.get(obj.status, []) + + def _get_customer_participant(self, obj): + """Get the customer User from participants.""" + from django.contrib.contenttypes.models import ContentType + from smoothschedule.users.models import User + + if not hasattr(self, '_customer_cache'): + self._customer_cache = {} + + if obj.id in self._customer_cache: + return self._customer_cache[obj.id] + + try: + user_ct = ContentType.objects.get_for_model(User) + participant = obj.participants.filter( + role=Participant.Role.CUSTOMER, + content_type=user_ct + ).first() + + if participant: + self._customer_cache[obj.id] = participant.content_object + return participant.content_object + except Exception: + pass + + return None + + +class JobDetailSerializer(serializers.ModelSerializer): + """ + Full job details for the job detail screen. + + Includes all information needed to work on the job. + """ + service = ServiceSummarySerializer(read_only=True) + customer = serializers.SerializerMethodField() + assigned_staff = serializers.SerializerMethodField() + status_display = serializers.CharField(source='get_status_display', read_only=True) + duration_minutes = serializers.SerializerMethodField() + allowed_transitions = serializers.SerializerMethodField() + can_track_location = serializers.SerializerMethodField() + has_active_call_session = serializers.SerializerMethodField() + status_history = serializers.SerializerMethodField() + latest_location = serializers.SerializerMethodField() + can_edit_schedule = serializers.SerializerMethodField() + + class Meta: + model = Event + fields = [ + 'id', + 'title', + 'start_time', + 'end_time', + 'status', + 'status_display', + 'notes', + 'service', + 'customer', + 'assigned_staff', + 'duration_minutes', + 'allowed_transitions', + 'can_track_location', + 'has_active_call_session', + 'status_history', + 'latest_location', + 'deposit_amount', + 'final_price', + 'created_at', + 'updated_at', + 'can_edit_schedule', + ] + + def get_customer(self, obj): + """Get customer info with masked phone.""" + customer = self._get_customer_participant(obj) + if customer: + return CustomerInfoSerializer(customer).data + return None + + def get_assigned_staff(self, obj): + """Get list of assigned staff members.""" + from django.contrib.contenttypes.models import ContentType + from smoothschedule.users.models import User + from schedule.models import Resource + + staff = [] + + # Get staff from User participants + user_ct = ContentType.objects.get_for_model(User) + for participant in obj.participants.filter( + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE], + content_type=user_ct + ): + user = participant.content_object + if user: + staff.append({ + 'id': user.id, + 'name': user.full_name or user.username, + 'type': 'user', + }) + + # Get staff from Resource participants + resource_ct = ContentType.objects.get_for_model(Resource) + for participant in obj.participants.filter( + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE], + content_type=resource_ct + ): + resource = participant.content_object + if resource: + staff.append({ + 'id': resource.id, + 'name': resource.name, + 'type': 'resource', + 'user_id': resource.user_id, + }) + + return staff + + def get_duration_minutes(self, obj): + if obj.start_time and obj.end_time: + delta = obj.end_time - obj.start_time + return int(delta.total_seconds() / 60) + return None + + def get_allowed_transitions(self, obj): + from smoothschedule.field_mobile.services import StatusMachine + return StatusMachine.VALID_TRANSITIONS.get(obj.status, []) + + def get_can_track_location(self, obj): + """Check if location tracking is allowed for current status.""" + from smoothschedule.field_mobile.services import StatusMachine + return obj.status in StatusMachine.TRACKING_STATUSES + + def get_has_active_call_session(self, obj): + """Check if there's an active masked call session.""" + tenant = self.context.get('tenant') + if not tenant: + return False + + from smoothschedule.comms_credits.models import MaskedSession + return MaskedSession.objects.filter( + tenant=tenant, + event_id=obj.id, + status=MaskedSession.Status.ACTIVE, + expires_at__gt=timezone.now() + ).exists() + + def get_status_history(self, obj): + """Get recent status change history.""" + tenant = self.context.get('tenant') + if not tenant: + return [] + + history = EventStatusHistory.objects.filter( + tenant=tenant, + event_id=obj.id + ).select_related('changed_by')[:10] + + return [ + { + 'old_status': h.old_status, + 'new_status': h.new_status, + 'changed_by': h.changed_by.full_name if h.changed_by else 'System', + 'changed_at': h.changed_at, + 'notes': h.notes, + } + for h in history + ] + + def get_latest_location(self, obj): + """Get the most recent location update for this job.""" + tenant = self.context.get('tenant') + if not tenant: + return None + + location = EmployeeLocationUpdate.get_latest_for_event( + tenant_id=tenant.id, + event_id=obj.id + ) + + if location: + return { + 'latitude': float(location.latitude), + 'longitude': float(location.longitude), + 'timestamp': location.timestamp, + 'accuracy': location.accuracy, + } + return None + + def _get_customer_participant(self, obj): + from django.contrib.contenttypes.models import ContentType + from smoothschedule.users.models import User + + try: + user_ct = ContentType.objects.get_for_model(User) + participant = obj.participants.filter( + role=Participant.Role.CUSTOMER, + content_type=user_ct + ).first() + return participant.content_object if participant else None + except Exception: + return None + + def get_can_edit_schedule(self, obj): + """ + Check if the current user can edit this job's schedule. + + Returns True if the user's linked resource has user_can_edit_schedule=True. + """ + from django.contrib.contenttypes.models import ContentType + from schedule.models import Resource + + # Get the current user from context + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + + user = request.user + + # Get resources linked to this user + user_resources = Resource.objects.filter(user=user) + + # Check if any of the user's resources has edit permission + for resource in user_resources: + if resource.user_can_edit_schedule: + return True + + return False + + +class SetStatusSerializer(serializers.Serializer): + """Request to change a job's status.""" + status = serializers.ChoiceField(choices=Event.Status.choices) + notes = serializers.CharField(required=False, allow_blank=True, default='') + latitude = serializers.DecimalField( + max_digits=10, decimal_places=7, + required=False, allow_null=True + ) + longitude = serializers.DecimalField( + max_digits=10, decimal_places=7, + required=False, allow_null=True + ) + + +class RescheduleJobSerializer(serializers.Serializer): + """Request to reschedule a job (change start time and/or duration).""" + start_time = serializers.DateTimeField(required=False, allow_null=True) + end_time = serializers.DateTimeField(required=False, allow_null=True) + duration_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=5, max_value=1440) + + def validate(self, attrs): + """Validate that we have either start/end times or duration.""" + start_time = attrs.get('start_time') + end_time = attrs.get('end_time') + duration_minutes = attrs.get('duration_minutes') + + # Must have at least one field to update + if not start_time and not end_time and not duration_minutes: + raise serializers.ValidationError( + "Must provide start_time, end_time, or duration_minutes" + ) + + # If both start_time and end_time are provided, validate end > start + if start_time and end_time and end_time <= start_time: + raise serializers.ValidationError( + "end_time must be after start_time" + ) + + return attrs + + +class StartEnRouteSerializer(serializers.Serializer): + """Request to start en-route to a job (includes location).""" + latitude = serializers.DecimalField( + max_digits=10, decimal_places=7, + required=False, allow_null=True + ) + longitude = serializers.DecimalField( + max_digits=10, decimal_places=7, + required=False, allow_null=True + ) + send_customer_notification = serializers.BooleanField(default=True) + + +class LocationUpdateSerializer(serializers.Serializer): + """Employee location update while en-route or in-progress.""" + latitude = serializers.DecimalField(max_digits=10, decimal_places=7) + longitude = serializers.DecimalField(max_digits=10, decimal_places=7) + accuracy = serializers.FloatField(required=False, allow_null=True) + altitude = serializers.FloatField(required=False, allow_null=True) + heading = serializers.FloatField(required=False, allow_null=True) + speed = serializers.FloatField(required=False, allow_null=True) + timestamp = serializers.DateTimeField() + battery_level = serializers.FloatField(required=False, allow_null=True) + + +class LocationUpdateResponseSerializer(serializers.Serializer): + """Response after recording a location update.""" + success = serializers.BooleanField() + should_continue_tracking = serializers.BooleanField() + message = serializers.CharField(required=False) + + +class InitiateCallSerializer(serializers.Serializer): + """Request to initiate a masked call to customer.""" + # No required fields - customer phone comes from the job + pass + + +class InitiateCallResponseSerializer(serializers.Serializer): + """Response after initiating a call.""" + call_sid = serializers.CharField() + call_log_id = serializers.IntegerField() + proxy_number = serializers.CharField() + status = serializers.CharField() + message = serializers.CharField() + + +class SendSMSSerializer(serializers.Serializer): + """Request to send SMS to customer.""" + message = serializers.CharField(max_length=1600) + + +class SendSMSResponseSerializer(serializers.Serializer): + """Response after sending SMS.""" + message_sid = serializers.CharField() + call_log_id = serializers.IntegerField() + status = serializers.CharField() + + +class CallHistorySerializer(serializers.ModelSerializer): + """Call/SMS history for a job.""" + employee_name = serializers.SerializerMethodField() + type_display = serializers.CharField(source='get_call_type_display') + direction_display = serializers.CharField(source='get_direction_display') + status_display = serializers.CharField(source='get_status_display') + + class Meta: + model = FieldCallLog + fields = [ + 'id', + 'call_type', + 'type_display', + 'direction', + 'direction_display', + 'status', + 'status_display', + 'duration_seconds', + 'initiated_at', + 'answered_at', + 'ended_at', + 'employee_name', + ] + + def get_employee_name(self, obj): + return obj.employee.full_name if obj.employee else None + + +class EmployeeProfileSerializer(serializers.Serializer): + """ + Employee profile for the mobile app. + + Includes tenant context and permissions. + """ + id = serializers.IntegerField() + email = serializers.EmailField() + name = serializers.CharField() + phone = serializers.CharField(allow_null=True) + role = serializers.CharField() + + # Business context + business_id = serializers.IntegerField(source='tenant_id') + business_name = serializers.SerializerMethodField() + business_subdomain = serializers.SerializerMethodField() + + # Feature flags + can_use_masked_calls = serializers.SerializerMethodField() + can_track_location = serializers.SerializerMethodField() + + def get_business_name(self, obj): + return obj.tenant.name if obj.tenant else None + + def get_business_subdomain(self, obj): + if obj.tenant: + domain = obj.tenant.domains.filter(is_primary=True).first() + if domain: + return domain.domain.split('.')[0] + return None + + def get_can_use_masked_calls(self, obj): + if obj.tenant: + return obj.tenant.has_feature('can_use_masked_phone_numbers') + return False + + def get_can_track_location(self, obj): + if obj.tenant: + return obj.tenant.has_feature('can_use_mobile_app') + return False diff --git a/smoothschedule/smoothschedule/field_mobile/services/__init__.py b/smoothschedule/smoothschedule/field_mobile/services/__init__.py new file mode 100644 index 0000000..b14fdff --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/services/__init__.py @@ -0,0 +1,5 @@ +# Field Mobile Services +from .status_machine import StatusMachine +from .twilio_calls import TwilioFieldCallService + +__all__ = ['StatusMachine', 'TwilioFieldCallService'] diff --git a/smoothschedule/smoothschedule/field_mobile/services/status_machine.py b/smoothschedule/smoothschedule/field_mobile/services/status_machine.py new file mode 100644 index 0000000..d165146 --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/services/status_machine.py @@ -0,0 +1,306 @@ +""" +Status Machine Service + +Enforces valid status transitions for jobs/events and records history. +""" +from django.utils import timezone +from django.db import transaction +from typing import Optional, Tuple +from decimal import Decimal + +from schedule.models import Event +from smoothschedule.field_mobile.models import EventStatusHistory, EmployeeLocationUpdate + + +class StatusTransitionError(Exception): + """Raised when an invalid status transition is attempted.""" + pass + + +class StatusMachine: + """ + Manages event/job status transitions with validation and audit logging. + + Status Flow: + SCHEDULED → EN_ROUTE → IN_PROGRESS → COMPLETED + ↘ ↘ + CANCELED NOSHOW + ↓ + AWAITING_PAYMENT → PAID + + Rules: + - Only assigned employees can change status (except admin override) + - Cannot go backward in the flow (no COMPLETED → SCHEDULED) + - Location tracking stops on COMPLETED, CANCELED, NOSHOW + - Some transitions trigger customer notifications + """ + + # Define valid transitions: current_status -> [allowed_next_statuses] + VALID_TRANSITIONS = { + Event.Status.SCHEDULED: [ + Event.Status.EN_ROUTE, + Event.Status.IN_PROGRESS, # Can skip EN_ROUTE if already at location + Event.Status.CANCELED, + ], + Event.Status.EN_ROUTE: [ + Event.Status.IN_PROGRESS, + Event.Status.CANCELED, + Event.Status.NOSHOW, # Customer not available when arrived + ], + Event.Status.IN_PROGRESS: [ + Event.Status.COMPLETED, + Event.Status.CANCELED, + Event.Status.NOSHOW, + ], + Event.Status.COMPLETED: [ + Event.Status.AWAITING_PAYMENT, # For variable pricing + ], + Event.Status.AWAITING_PAYMENT: [ + Event.Status.PAID, + ], + # Terminal states - no transitions allowed + Event.Status.CANCELED: [], + Event.Status.PAID: [], + Event.Status.NOSHOW: [], + } + + # Statuses that allow location tracking + TRACKING_STATUSES = { + Event.Status.EN_ROUTE, + Event.Status.IN_PROGRESS, + } + + # Transitions that should trigger customer notification + NOTIFY_CUSTOMER_TRANSITIONS = { + (Event.Status.SCHEDULED, Event.Status.EN_ROUTE): 'en_route_notification', + (Event.Status.EN_ROUTE, Event.Status.IN_PROGRESS): 'arrived_notification', + (Event.Status.IN_PROGRESS, Event.Status.COMPLETED): 'completed_notification', + } + + def __init__(self, tenant, user): + """ + Initialize the status machine. + + Args: + tenant: The tenant (business) context + user: The user making the status change + """ + self.tenant = tenant + self.user = user + + def can_transition(self, event: Event, new_status: str) -> Tuple[bool, str]: + """ + Check if a status transition is valid. + + Args: + event: The event to check + new_status: The proposed new status + + Returns: + Tuple of (is_valid, reason_if_invalid) + """ + current_status = event.status + + # Same status - no change needed + if current_status == new_status: + return True, "" + + # Check if transition is in the allowed list + allowed = self.VALID_TRANSITIONS.get(current_status, []) + if new_status not in allowed: + return False, ( + f"Cannot transition from {current_status} to {new_status}. " + f"Allowed transitions: {', '.join(allowed) if allowed else 'none (terminal state)'}" + ) + + return True, "" + + def is_employee_assigned(self, event: Event) -> bool: + """ + Check if the current user is assigned to this event as staff. + + Returns True if: + - User is a participant with STAFF role, OR + - User is linked to a Resource that is a participant + """ + from django.contrib.contenttypes.models import ContentType + from schedule.models import Participant, Resource + from smoothschedule.users.models import User + + # Check if user is directly a participant + user_ct = ContentType.objects.get_for_model(User) + if Participant.objects.filter( + event=event, + content_type=user_ct, + object_id=self.user.id, + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE] + ).exists(): + return True + + # Check if user is linked to a Resource that is a participant + resource_ct = ContentType.objects.get_for_model(Resource) + user_resources = Resource.objects.filter(user=self.user).values_list('id', flat=True) + + if Participant.objects.filter( + event=event, + content_type=resource_ct, + object_id__in=user_resources, + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE] + ).exists(): + return True + + return False + + def can_user_change_status(self, event: Event) -> Tuple[bool, str]: + """ + Check if the current user has permission to change this event's status. + + Returns: + Tuple of (is_allowed, reason_if_not) + """ + from smoothschedule.users.models import User + + # Owners and managers can always change status + if self.user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: + return True, "" + + # Staff must be assigned to the event + if self.user.role == User.Role.TENANT_STAFF: + if self.is_employee_assigned(event): + return True, "" + return False, "You are not assigned to this job" + + return False, "You do not have permission to change job status" + + @transaction.atomic + def transition( + self, + event: Event, + new_status: str, + notes: str = "", + latitude: Optional[Decimal] = None, + longitude: Optional[Decimal] = None, + source: str = "mobile_app", + skip_notifications: bool = False, + ) -> Event: + """ + Transition an event to a new status. + + Args: + event: The event to update + new_status: The target status + notes: Optional notes about the change + latitude: GPS latitude at time of change + longitude: GPS longitude at time of change + source: Where the change originated + skip_notifications: If True, don't send customer notifications + + Returns: + The updated event + + Raises: + StatusTransitionError: If the transition is not allowed + """ + old_status = event.status + + # Check permission + can_change, reason = self.can_user_change_status(event) + if not can_change: + raise StatusTransitionError(reason) + + # Check if transition is valid + is_valid, reason = self.can_transition(event, new_status) + if not is_valid: + raise StatusTransitionError(reason) + + # Perform the transition + event.status = new_status + event.save(update_fields=['status', 'updated_at']) + + # Record in history + EventStatusHistory.objects.create( + tenant=self.tenant, + event_id=event.id, + old_status=old_status, + new_status=new_status, + changed_by=self.user, + notes=notes, + latitude=latitude, + longitude=longitude, + source=source, + ) + + # Handle location tracking cleanup if needed + if new_status not in self.TRACKING_STATUSES: + self._stop_location_tracking(event) + + # Trigger notifications if needed + if not skip_notifications: + notification_type = self.NOTIFY_CUSTOMER_TRANSITIONS.get( + (old_status, new_status) + ) + if notification_type: + self._send_customer_notification(event, notification_type) + + return event + + def _stop_location_tracking(self, event: Event): + """ + Mark that location tracking should stop for this event. + + The mobile app checks this to know when to stop sending updates. + We don't delete existing location data - it's kept for the route history. + """ + # Future: Could add a field to mark tracking as stopped + # For now, the app will check the event status before sending updates + pass + + def _send_customer_notification(self, event: Event, notification_type: str): + """ + Send a notification to the customer about the status change. + + Args: + event: The event that changed + notification_type: Type of notification to send + """ + # Import here to avoid circular imports + from smoothschedule.field_mobile.tasks import send_customer_status_notification + + try: + # Queue the notification task + send_customer_status_notification.delay( + tenant_id=self.tenant.id, + event_id=event.id, + notification_type=notification_type, + ) + except Exception: + # Don't fail the status change if notification fails + # The notification system should handle retries + pass + + def get_allowed_transitions(self, event: Event) -> list: + """ + Get the list of statuses this event can transition to. + + Returns: + List of allowed status values + """ + return self.VALID_TRANSITIONS.get(event.status, []) + + def get_status_history(self, event_id: int, limit: int = 50) -> list: + """ + Get the status change history for an event. + + Args: + event_id: The event ID + limit: Maximum number of records to return + + Returns: + List of EventStatusHistory records + """ + return list( + EventStatusHistory.objects.filter( + tenant=self.tenant, + event_id=event_id + ).select_related('changed_by')[:limit] + ) diff --git a/smoothschedule/smoothschedule/field_mobile/services/twilio_calls.py b/smoothschedule/smoothschedule/field_mobile/services/twilio_calls.py new file mode 100644 index 0000000..9ed0472 --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/services/twilio_calls.py @@ -0,0 +1,609 @@ +""" +Twilio Field Call Service + +Handles masked calling and SMS between field employees and customers. +Bi-directional: both employee→customer and customer→employee are supported. +""" +import logging +from datetime import timedelta +from typing import Optional, Tuple +from django.conf import settings +from django.utils import timezone +from django.db import transaction + +logger = logging.getLogger(__name__) + + +class TwilioFieldCallError(Exception): + """Raised when a Twilio call operation fails.""" + pass + + +class TwilioFieldCallService: + """ + Service for managing masked calls and SMS for field employees. + + Uses Twilio Proxy-like functionality where: + - A proxy number is assigned to each job session + - Employee and customer both call/text the proxy number + - Twilio webhooks route calls to the appropriate party + """ + + # How long a masked session lasts (extends past job end) + SESSION_DURATION_HOURS = 4 + + def __init__(self, tenant): + """ + Initialize the service for a tenant. + + Args: + tenant: The tenant (business) making the call + """ + self.tenant = tenant + self._client = None + + @property + def client(self): + """Lazy-load Twilio client using tenant's subaccount credentials.""" + if self._client is None: + from twilio.rest import Client + + # Check for tenant subaccount first + if self.tenant.twilio_subaccount_sid and self.tenant.twilio_subaccount_auth_token: + self._client = Client( + self.tenant.twilio_subaccount_sid, + self.tenant.twilio_subaccount_auth_token + ) + else: + # Fall back to master account (not recommended for production) + master_sid = getattr(settings, 'TWILIO_ACCOUNT_SID', '') + master_token = getattr(settings, 'TWILIO_AUTH_TOKEN', '') + + if not master_sid or not master_token: + raise TwilioFieldCallError( + "Twilio is not configured for this business. " + "Please contact support." + ) + + self._client = Client(master_sid, master_token) + logger.warning( + f"Using master Twilio account for tenant {self.tenant.name}. " + "This should be avoided in production." + ) + + return self._client + + def _get_or_create_session( + self, + event_id: int, + employee_phone: str, + customer_phone: str + ): + """ + Get existing masked session for an event, or create a new one. + + Args: + event_id: The job/event ID + employee_phone: Employee's real phone number + customer_phone: Customer's real phone number + + Returns: + MaskedSession instance + """ + from smoothschedule.comms_credits.models import MaskedSession, ProxyPhoneNumber + + # Check for existing active session + existing = MaskedSession.objects.filter( + tenant=self.tenant, + event_id=event_id, + status=MaskedSession.Status.ACTIVE, + expires_at__gt=timezone.now() + ).first() + + if existing: + # Update phone numbers if changed (shouldn't happen but be safe) + if existing.staff_phone != employee_phone or existing.customer_phone != customer_phone: + existing.staff_phone = employee_phone + existing.customer_phone = customer_phone + existing.save(update_fields=['staff_phone', 'customer_phone', 'updated_at']) + return existing + + # Need to create a new session - get a proxy number + proxy_number = self._get_available_proxy_number() + if not proxy_number: + raise TwilioFieldCallError( + "No proxy numbers available. Please contact support." + ) + + # Create the session + session = MaskedSession.objects.create( + tenant=self.tenant, + event_id=event_id, + proxy_number=proxy_number, + customer_phone=customer_phone, + staff_phone=employee_phone, + expires_at=timezone.now() + timedelta(hours=self.SESSION_DURATION_HOURS), + status=MaskedSession.Status.ACTIVE, + ) + + # Mark the proxy number as reserved for this session + proxy_number.status = ProxyPhoneNumber.Status.RESERVED + proxy_number.save(update_fields=['status', 'updated_at']) + + logger.info( + f"Created masked session {session.id} for event {event_id} " + f"using proxy {proxy_number.phone_number}" + ) + + return session + + def _get_available_proxy_number(self): + """ + Get an available proxy number from the pool. + + Prefers: + 1. Numbers already assigned to this tenant + 2. Numbers in the shared pool (AVAILABLE status) + """ + from smoothschedule.comms_credits.models import ProxyPhoneNumber + + # First, try tenant's assigned numbers + tenant_number = ProxyPhoneNumber.objects.filter( + assigned_tenant=self.tenant, + status=ProxyPhoneNumber.Status.ASSIGNED, + is_active=True, + ).first() + + if tenant_number: + return tenant_number + + # Fall back to shared pool + available = ProxyPhoneNumber.objects.filter( + status=ProxyPhoneNumber.Status.AVAILABLE, + is_active=True, + ).first() + + return available + + def _check_feature_permission(self): + """Check if tenant has masked calling feature enabled.""" + if not self.tenant.has_feature('can_use_masked_phone_numbers'): + raise TwilioFieldCallError( + "Masked calling is not available on your current plan. " + "Please upgrade to access this feature." + ) + + def _check_credits(self, estimated_cost_cents: int = 50): + """ + Check if tenant has sufficient communication credits. + + Args: + estimated_cost_cents: Estimated cost of the call/SMS + """ + from smoothschedule.comms_credits.models import CommunicationCredits + + try: + credits = CommunicationCredits.objects.get(tenant=self.tenant) + if credits.balance_cents < estimated_cost_cents: + raise TwilioFieldCallError( + "Insufficient communication credits. " + f"Current balance: ${credits.balance_cents/100:.2f}" + ) + except CommunicationCredits.DoesNotExist: + raise TwilioFieldCallError( + "Communication credits not set up. " + "Please add credits to use calling features." + ) + + def _get_customer_phone_for_event(self, event_id: int) -> Optional[str]: + """ + Get the customer's phone number for an event. + + Looks up the customer participant and returns their phone. + """ + from django.contrib.contenttypes.models import ContentType + from schedule.models import Event, Participant + from smoothschedule.users.models import User + from django_tenants.utils import schema_context + + with schema_context(self.tenant.schema_name): + try: + event = Event.objects.get(id=event_id) + except Event.DoesNotExist: + return None + + # Find customer participant + user_ct = ContentType.objects.get_for_model(User) + customer_participant = Participant.objects.filter( + event=event, + role=Participant.Role.CUSTOMER, + content_type=user_ct + ).first() + + if customer_participant: + customer = customer_participant.content_object + if customer and hasattr(customer, 'phone') and customer.phone: + return customer.phone + + return None + + @transaction.atomic + def initiate_call( + self, + event_id: int, + employee, + customer_phone: Optional[str] = None, + ) -> dict: + """ + Initiate a masked call from employee to customer. + + The employee's phone will ring first. When they answer, + the customer's phone will be connected. + + Args: + event_id: The job/event ID + employee: The employee User making the call + customer_phone: Customer's phone (optional, will look up from event) + + Returns: + Dict with call_sid, proxy_number, status + """ + from smoothschedule.field_mobile.models import FieldCallLog + + # Check permissions and credits + self._check_feature_permission() + self._check_credits(50) # Voice calls cost more + + # Get customer phone if not provided + if not customer_phone: + customer_phone = self._get_customer_phone_for_event(event_id) + if not customer_phone: + raise TwilioFieldCallError( + "Customer phone number not found for this job." + ) + + # Get employee phone + if not employee.phone: + raise TwilioFieldCallError( + "Your phone number is not set. Please update your profile." + ) + + employee_phone = employee.phone + + # Get or create masked session + session = self._get_or_create_session( + event_id=event_id, + employee_phone=employee_phone, + customer_phone=customer_phone, + ) + + # Build callback URL for Twilio + callback_url = self._get_callback_url('voice', session.id) + + try: + # Create the call - connect employee first, then bridge to customer + call = self.client.calls.create( + to=employee_phone, # Call employee first + from_=session.proxy_number.phone_number, + url=callback_url, + status_callback=self._get_status_callback_url(session.id), + status_callback_event=['initiated', 'ringing', 'answered', 'completed'], + machine_detection='Enable', # Detect voicemail + ) + + # Log the call + call_log = FieldCallLog.objects.create( + tenant=self.tenant, + event_id=event_id, + call_type=FieldCallLog.CallType.VOICE, + direction=FieldCallLog.Direction.OUTBOUND, + status=FieldCallLog.Status.INITIATED, + employee=employee, + customer_phone=customer_phone, + proxy_number=session.proxy_number.phone_number, + twilio_call_sid=call.sid, + masked_session=session, + ) + + logger.info( + f"Initiated call {call.sid} from employee {employee.id} " + f"to customer via proxy {session.proxy_number.phone_number}" + ) + + return { + 'call_sid': call.sid, + 'call_log_id': call_log.id, + 'proxy_number': session.proxy_number.phone_number, + 'status': 'initiated', + 'message': 'Your phone will ring shortly. Answer to connect to the customer.', + } + + except Exception as e: + logger.error(f"Error initiating call: {e}") + raise TwilioFieldCallError(f"Failed to initiate call: {str(e)}") + + @transaction.atomic + def send_sms( + self, + event_id: int, + employee, + message: str, + customer_phone: Optional[str] = None, + ) -> dict: + """ + Send a masked SMS from employee to customer. + + The customer will see the proxy number as the sender. + + Args: + event_id: The job/event ID + employee: The employee User sending the SMS + message: The message to send + customer_phone: Customer's phone (optional) + + Returns: + Dict with message_sid, status + """ + from smoothschedule.field_mobile.models import FieldCallLog + + # Check permissions and credits + self._check_feature_permission() + self._check_credits(5) # SMS costs less + + # Validate message + if not message or len(message.strip()) == 0: + raise TwilioFieldCallError("Message cannot be empty") + + if len(message) > 1600: # Twilio limit + raise TwilioFieldCallError("Message too long (max 1600 characters)") + + # Get customer phone if not provided + if not customer_phone: + customer_phone = self._get_customer_phone_for_event(event_id) + if not customer_phone: + raise TwilioFieldCallError( + "Customer phone number not found for this job." + ) + + if not employee.phone: + raise TwilioFieldCallError( + "Your phone number is not set. Please update your profile." + ) + + # Get or create masked session + session = self._get_or_create_session( + event_id=event_id, + employee_phone=employee.phone, + customer_phone=customer_phone, + ) + + try: + # Send the SMS + sms = self.client.messages.create( + to=customer_phone, + from_=session.proxy_number.phone_number, + body=message, + status_callback=self._get_sms_status_callback_url(session.id), + ) + + # Log the message + call_log = FieldCallLog.objects.create( + tenant=self.tenant, + event_id=event_id, + call_type=FieldCallLog.CallType.SMS, + direction=FieldCallLog.Direction.OUTBOUND, + status=FieldCallLog.Status.COMPLETED, # SMS is instant + employee=employee, + customer_phone=customer_phone, + proxy_number=session.proxy_number.phone_number, + twilio_message_sid=sms.sid, + masked_session=session, + ) + + # Update session SMS count + session.sms_count += 1 + session.save(update_fields=['sms_count', 'updated_at']) + + logger.info( + f"Sent SMS {sms.sid} from employee {employee.id} " + f"to customer via proxy {session.proxy_number.phone_number}" + ) + + return { + 'message_sid': sms.sid, + 'call_log_id': call_log.id, + 'status': 'sent', + } + + except Exception as e: + logger.error(f"Error sending SMS: {e}") + raise TwilioFieldCallError(f"Failed to send SMS: {str(e)}") + + def get_session_for_event(self, event_id: int): + """ + Get the active masked session for an event. + + Returns None if no active session exists. + """ + from smoothschedule.comms_credits.models import MaskedSession + + return MaskedSession.objects.filter( + tenant=self.tenant, + event_id=event_id, + status=MaskedSession.Status.ACTIVE, + expires_at__gt=timezone.now() + ).first() + + def close_session(self, event_id: int): + """ + Close the masked session for an event. + + Called when a job is completed to stop allowing calls/SMS. + """ + from smoothschedule.comms_credits.models import MaskedSession + + session = self.get_session_for_event(event_id) + if session: + session.close() + logger.info(f"Closed masked session {session.id} for event {event_id}") + + def get_call_history(self, event_id: int, limit: int = 20) -> list: + """ + Get call/SMS history for an event. + + Args: + event_id: The job/event ID + limit: Maximum records to return + + Returns: + List of FieldCallLog records + """ + from smoothschedule.field_mobile.models import FieldCallLog + + return list( + FieldCallLog.objects.filter( + tenant=self.tenant, + event_id=event_id + ).select_related('employee')[:limit] + ) + + def _get_callback_url(self, call_type: str, session_id: int) -> str: + """Build Twilio webhook callback URL for voice calls.""" + base_url = getattr(settings, 'TWILIO_WEBHOOK_BASE_URL', '') + if not base_url: + # Fall back to site URL + base_url = getattr(settings, 'SITE_URL', 'https://api.smoothschedule.com') + + return f"{base_url}/api/mobile/twilio/voice/{session_id}/" + + def _get_status_callback_url(self, session_id: int) -> str: + """Build Twilio status callback URL.""" + base_url = getattr(settings, 'TWILIO_WEBHOOK_BASE_URL', '') + if not base_url: + base_url = getattr(settings, 'SITE_URL', 'https://api.smoothschedule.com') + + return f"{base_url}/api/mobile/twilio/voice-status/{session_id}/" + + def _get_sms_status_callback_url(self, session_id: int) -> str: + """Build Twilio SMS status callback URL.""" + base_url = getattr(settings, 'TWILIO_WEBHOOK_BASE_URL', '') + if not base_url: + base_url = getattr(settings, 'SITE_URL', 'https://api.smoothschedule.com') + + return f"{base_url}/api/mobile/twilio/sms-status/{session_id}/" + + +def handle_incoming_call(session_id: int, from_number: str) -> str: + """ + Handle an incoming call to a proxy number. + + This is called by the Twilio webhook when someone calls the proxy. + Routes the call to the appropriate party. + + Args: + session_id: The MaskedSession ID + from_number: The caller's phone number + + Returns: + TwiML response string + """ + from smoothschedule.comms_credits.models import MaskedSession + from twilio.twiml.voice_response import VoiceResponse + + response = VoiceResponse() + + try: + session = MaskedSession.objects.select_related('proxy_number').get(id=session_id) + + if not session.is_active(): + response.say("This number is no longer in service for this appointment.") + response.hangup() + return str(response) + + # Determine who to connect to + destination = session.get_destination_for_caller(from_number) + + if not destination: + response.say("Unable to connect your call. Please try again later.") + response.hangup() + return str(response) + + # Connect the call + response.dial( + destination, + caller_id=session.proxy_number.phone_number, + timeout=30, + ) + + return str(response) + + except MaskedSession.DoesNotExist: + response.say("This number is not currently in service.") + response.hangup() + return str(response) + + +def handle_incoming_sms(session_id: int, from_number: str, body: str) -> str: + """ + Handle an incoming SMS to a proxy number. + + Routes the SMS to the appropriate party. + + Args: + session_id: The MaskedSession ID + from_number: The sender's phone number + body: The SMS body + + Returns: + TwiML response string (empty for SMS) + """ + from smoothschedule.comms_credits.models import MaskedSession + from twilio.rest import Client + from django.conf import settings as django_settings + + try: + session = MaskedSession.objects.select_related( + 'proxy_number', 'tenant' + ).get(id=session_id) + + if not session.is_active(): + # Session expired - don't forward + return "" + + # Determine where to forward + destination = session.get_destination_for_caller(from_number) + + if not destination: + return "" + + # Get Twilio client for tenant + tenant = session.tenant + if tenant.twilio_subaccount_sid and tenant.twilio_subaccount_auth_token: + client = Client( + tenant.twilio_subaccount_sid, + tenant.twilio_subaccount_auth_token + ) + else: + client = Client( + django_settings.TWILIO_ACCOUNT_SID, + django_settings.TWILIO_AUTH_TOKEN + ) + + # Forward the SMS + client.messages.create( + to=destination, + from_=session.proxy_number.phone_number, + body=body, + ) + + # Update session SMS count + session.sms_count += 1 + session.save(update_fields=['sms_count', 'updated_at']) + + return "" + + except MaskedSession.DoesNotExist: + return "" + except Exception as e: + logger.error(f"Error forwarding SMS: {e}") + return "" diff --git a/smoothschedule/smoothschedule/field_mobile/tasks.py b/smoothschedule/smoothschedule/field_mobile/tasks.py new file mode 100644 index 0000000..462f266 --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/tasks.py @@ -0,0 +1,271 @@ +""" +Field Mobile Celery Tasks + +Background tasks for notifications and cleanup. +""" +import logging +from celery import shared_task +from django.conf import settings +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +@shared_task +def send_customer_status_notification(tenant_id, event_id, notification_type): + """ + Send a notification to the customer about a job status change. + + Args: + tenant_id: The tenant ID + event_id: The event/job ID + notification_type: One of 'en_route_notification', 'arrived_notification', 'completed_notification' + """ + from core.models import Tenant + from django_tenants.utils import schema_context + from schedule.models import Event, Participant + from django.contrib.contenttypes.models import ContentType + from smoothschedule.users.models import User + + try: + tenant = Tenant.objects.get(id=tenant_id) + except Tenant.DoesNotExist: + logger.error(f"Tenant {tenant_id} not found") + return {'error': 'Tenant not found'} + + with schema_context(tenant.schema_name): + try: + event = Event.objects.get(id=event_id) + except Event.DoesNotExist: + logger.error(f"Event {event_id} not found") + return {'error': 'Event not found'} + + # Get customer from participants + user_ct = ContentType.objects.get_for_model(User) + customer_participant = Participant.objects.filter( + event=event, + role=Participant.Role.CUSTOMER, + content_type=user_ct + ).first() + + if not customer_participant: + logger.warning(f"No customer found for event {event_id}") + return {'error': 'No customer found'} + + customer = customer_participant.content_object + if not customer: + return {'error': 'Customer object not found'} + + # Determine notification content based on type + messages = { + 'en_route_notification': { + 'sms': f"Your technician from {tenant.name} is on the way! They should arrive soon.", + 'subject': f"Technician En Route - {tenant.name}", + }, + 'arrived_notification': { + 'sms': f"Your technician from {tenant.name} has arrived and is starting work.", + 'subject': f"Technician Arrived - {tenant.name}", + }, + 'completed_notification': { + 'sms': f"Your appointment with {tenant.name} has been completed. Thank you!", + 'subject': f"Appointment Completed - {tenant.name}", + }, + } + + content = messages.get(notification_type, {}) + if not content: + logger.warning(f"Unknown notification type: {notification_type}") + return {'error': f'Unknown notification type: {notification_type}'} + + # Send SMS if customer has phone and tenant has SMS enabled + if customer.phone and tenant.can_use_sms_reminders: + try: + send_sms_notification.delay( + tenant_id=tenant_id, + phone_number=customer.phone, + message=content['sms'], + ) + except Exception as e: + logger.error(f"Error queuing SMS: {e}") + + # Send email notification + if customer.email: + try: + send_email_notification.delay( + tenant_id=tenant_id, + email=customer.email, + subject=content['subject'], + message=content['sms'], # Use SMS content as email body for now + customer_name=customer.full_name or 'Customer', + ) + except Exception as e: + logger.error(f"Error queuing email: {e}") + + logger.info( + f"Queued {notification_type} for event {event_id}, " + f"customer: {customer.email}" + ) + return {'success': True, 'notification_type': notification_type} + + +@shared_task +def send_sms_notification(tenant_id, phone_number, message): + """ + Send an SMS notification using the tenant's Twilio account. + + Args: + tenant_id: The tenant ID + phone_number: Recipient phone number + message: SMS message body + """ + from core.models import Tenant + from smoothschedule.comms_credits.models import CommunicationCredits + + try: + tenant = Tenant.objects.get(id=tenant_id) + except Tenant.DoesNotExist: + return {'error': 'Tenant not found'} + + # Check credits + try: + credits = CommunicationCredits.objects.get(tenant=tenant) + if credits.balance_cents < 5: # Minimum for SMS + logger.warning(f"Insufficient credits for tenant {tenant.name}") + return {'error': 'Insufficient credits'} + except CommunicationCredits.DoesNotExist: + return {'error': 'Credits not configured'} + + # Get Twilio client + if not tenant.twilio_subaccount_sid: + return {'error': 'Twilio not configured'} + + try: + from twilio.rest import Client + + client = Client( + tenant.twilio_subaccount_sid, + tenant.twilio_subaccount_auth_token + ) + + # Use tenant's phone number or default + from_number = tenant.twilio_phone_number + if not from_number: + from_number = getattr(settings, 'TWILIO_DEFAULT_FROM_NUMBER', '') + + if not from_number: + return {'error': 'No from number configured'} + + # Send SMS + sms = client.messages.create( + to=phone_number, + from_=from_number, + body=message, + ) + + # Deduct credits + credits.deduct( + 5, # SMS cost in cents + f"Status notification SMS to {phone_number[-4:]}", + reference_type='notification_sms', + reference_id=sms.sid, + ) + + logger.info(f"Sent SMS notification {sms.sid} to {phone_number[-4:]}") + return {'success': True, 'message_sid': sms.sid} + + except Exception as e: + logger.error(f"Error sending SMS: {e}") + return {'error': str(e)} + + +@shared_task +def send_email_notification(tenant_id, email, subject, message, customer_name='Customer'): + """ + Send an email notification. + + Args: + tenant_id: The tenant ID + email: Recipient email address + subject: Email subject + message: Email body + customer_name: Customer's name for personalization + """ + from core.models import Tenant + from django.core.mail import send_mail + + try: + tenant = Tenant.objects.get(id=tenant_id) + except Tenant.DoesNotExist: + return {'error': 'Tenant not found'} + + # Build email body + email_body = f""" +Hi {customer_name}, + +{message} + +Best regards, +{tenant.name} +""" + + try: + from_email = tenant.contact_email or settings.DEFAULT_FROM_EMAIL + send_mail( + subject, + email_body, + from_email, + [email], + fail_silently=False, + ) + + logger.info(f"Sent email notification to {email}") + return {'success': True} + + except Exception as e: + logger.error(f"Error sending email: {e}") + return {'error': str(e)} + + +@shared_task +def cleanup_old_location_data(days_to_keep=30): + """ + Clean up old location tracking data. + + Removes location updates older than the specified number of days. + This is a privacy measure to not retain location data indefinitely. + + Args: + days_to_keep: Number of days of data to retain (default 30) + """ + from smoothschedule.field_mobile.models import EmployeeLocationUpdate + + cutoff = timezone.now() - timezone.timedelta(days=days_to_keep) + + deleted_count, _ = EmployeeLocationUpdate.objects.filter( + created_at__lt=cutoff + ).delete() + + logger.info(f"Deleted {deleted_count} old location updates (older than {days_to_keep} days)") + return {'deleted': deleted_count} + + +@shared_task +def cleanup_old_status_history(days_to_keep=365): + """ + Clean up old status history records. + + Keeps status history for longer (1 year default) for auditing. + + Args: + days_to_keep: Number of days of data to retain (default 365) + """ + from smoothschedule.field_mobile.models import EventStatusHistory + + cutoff = timezone.now() - timezone.timedelta(days=days_to_keep) + + deleted_count, _ = EventStatusHistory.objects.filter( + changed_at__lt=cutoff + ).delete() + + logger.info(f"Deleted {deleted_count} old status history records") + return {'deleted': deleted_count} diff --git a/smoothschedule/smoothschedule/field_mobile/urls.py b/smoothschedule/smoothschedule/field_mobile/urls.py new file mode 100644 index 0000000..9671185 --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/urls.py @@ -0,0 +1,63 @@ +""" +Field Mobile URL Configuration + +All endpoints are mounted under /api/mobile/ +""" +from django.urls import path + +from .views import ( + # Employee profile + employee_profile_view, + logout_view, + # Job endpoints + job_list_view, + job_detail_view, + # Status management + set_status_view, + start_en_route_view, + reschedule_job_view, + # Location tracking + location_update_view, + location_route_view, + # Calling and SMS + call_customer_view, + send_sms_view, + call_history_view, + # Twilio webhooks + twilio_voice_webhook, + twilio_voice_status_webhook, + twilio_sms_webhook, + twilio_sms_status_webhook, +) + +app_name = 'field_mobile' + +urlpatterns = [ + # Employee profile & auth + path('me/', employee_profile_view, name='employee_profile'), + path('logout/', logout_view, name='logout'), + + # Job management + path('jobs/', job_list_view, name='job_list'), + path('jobs//', job_detail_view, name='job_detail'), + + # Status management + path('jobs//set_status/', set_status_view, name='set_status'), + path('jobs//start_en_route/', start_en_route_view, name='start_en_route'), + path('jobs//reschedule/', reschedule_job_view, name='reschedule_job'), + + # Location tracking + path('jobs//location_update/', location_update_view, name='location_update'), + path('jobs//route/', location_route_view, name='location_route'), + + # Calling and SMS + path('jobs//call_customer/', call_customer_view, name='call_customer'), + path('jobs//send_sms/', send_sms_view, name='send_sms'), + path('jobs//call_history/', call_history_view, name='call_history'), + + # Twilio webhooks (public, no auth required) + path('twilio/voice//', twilio_voice_webhook, name='twilio_voice'), + path('twilio/voice-status//', twilio_voice_status_webhook, name='twilio_voice_status'), + path('twilio/sms//', twilio_sms_webhook, name='twilio_sms'), + path('twilio/sms-status//', twilio_sms_status_webhook, name='twilio_sms_status'), +] diff --git a/smoothschedule/smoothschedule/field_mobile/views.py b/smoothschedule/smoothschedule/field_mobile/views.py new file mode 100644 index 0000000..cea8e6f --- /dev/null +++ b/smoothschedule/smoothschedule/field_mobile/views.py @@ -0,0 +1,887 @@ +""" +Field Mobile API Views + +REST API endpoints for the field employee mobile app. +""" +import logging +from datetime import timedelta +from django.utils import timezone +from django.shortcuts import get_object_or_404 +from django.db.models import Q +from django.contrib.contenttypes.models import ContentType +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponse + +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response + +from django_tenants.utils import schema_context + +from schedule.models import Event, Participant, Resource +from smoothschedule.users.models import User +from smoothschedule.field_mobile.models import ( + EventStatusHistory, + EmployeeLocationUpdate, + FieldCallLog, +) +from smoothschedule.field_mobile.serializers import ( + JobListSerializer, + JobDetailSerializer, + SetStatusSerializer, + RescheduleJobSerializer, + StartEnRouteSerializer, + LocationUpdateSerializer, + LocationUpdateResponseSerializer, + InitiateCallSerializer, + InitiateCallResponseSerializer, + SendSMSSerializer, + SendSMSResponseSerializer, + CallHistorySerializer, + EmployeeProfileSerializer, +) +from smoothschedule.field_mobile.services import StatusMachine, TwilioFieldCallService +from smoothschedule.field_mobile.services.status_machine import StatusTransitionError +from smoothschedule.field_mobile.services.twilio_calls import TwilioFieldCallError + +logger = logging.getLogger(__name__) + + +def get_tenant_from_user(user): + """Get the tenant for an authenticated user.""" + if not user.tenant: + return None + return user.tenant + + +def is_field_employee(user): + """Check if user is a field employee (staff role).""" + return user.role in [ + User.Role.TENANT_STAFF, + User.Role.TENANT_MANAGER, + User.Role.TENANT_OWNER, + ] + + +def get_employee_jobs_queryset(user, tenant): + """ + Get the queryset of jobs assigned to an employee. + + Returns events where the user is a participant with STAFF/RESOURCE role, + or where a Resource linked to the user is a participant. + """ + user_ct = ContentType.objects.get_for_model(User) + resource_ct = ContentType.objects.get_for_model(Resource) + + # Get resource IDs linked to this user + user_resource_ids = list( + Resource.objects.filter(user=user).values_list('id', flat=True) + ) + + # Find events where user is directly a participant + user_event_ids = Participant.objects.filter( + content_type=user_ct, + object_id=user.id, + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE] + ).values_list('event_id', flat=True) + + # Find events where user's resource is a participant + resource_event_ids = [] + if user_resource_ids: + resource_event_ids = Participant.objects.filter( + content_type=resource_ct, + object_id__in=user_resource_ids, + role__in=[Participant.Role.STAFF, Participant.Role.RESOURCE] + ).values_list('event_id', flat=True) + + # Combine event IDs + all_event_ids = set(user_event_ids) | set(resource_event_ids) + + return Event.objects.filter(id__in=all_event_ids) + + +# ============================================================================= +# Employee Profile Endpoint +# ============================================================================= + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def employee_profile_view(request): + """ + Get the current employee's profile. + + GET /api/mobile/me/ + + Returns employee info with business context and feature flags. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not is_field_employee(user): + return Response( + {'error': 'This app is for field employees only'}, + status=status.HTTP_403_FORBIDDEN + ) + + serializer = EmployeeProfileSerializer(user) + return Response(serializer.data) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def logout_view(request): + """ + Logout from the mobile app. + + POST /api/mobile/logout/ + + NOTE: We do NOT delete the token because DRF tokens are OneToOne with User, + meaning web and mobile share the same token. Deleting it here would log + the user out of the web app too. + + The mobile app should clear its local token storage on logout. + """ + logger.info(f"User {request.user.id} logged out from mobile app (token preserved)") + return Response({'success': True, 'message': 'Logged out successfully'}) + + +# ============================================================================= +# Job List and Detail Endpoints +# ============================================================================= + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def job_list_view(request): + """ + List jobs assigned to the current employee. + + GET /api/mobile/jobs/ + + Query params: + - date: Filter by date (YYYY-MM-DD). Defaults to today. + - status: Filter by status (comma-separated) + - upcoming: If true, include future jobs + + Returns jobs sorted by start time. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not is_field_employee(user): + return Response( + {'error': 'This app is for field employees only'}, + status=status.HTTP_403_FORBIDDEN + ) + + with schema_context(tenant.schema_name): + queryset = get_employee_jobs_queryset(user, tenant) + + # Date filtering + date_str = request.query_params.get('date') + include_upcoming = request.query_params.get('upcoming', 'false').lower() == 'true' + + if date_str: + try: + from datetime import datetime + filter_date = datetime.strptime(date_str, '%Y-%m-%d').date() + queryset = queryset.filter( + start_time__date=filter_date + ) + except ValueError: + return Response( + {'error': 'Invalid date format. Use YYYY-MM-DD'}, + status=status.HTTP_400_BAD_REQUEST + ) + elif include_upcoming: + # Show today and future jobs (using business timezone) + import pytz + business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC + now_business = timezone.now().astimezone(business_tz) + today_start = now_business.replace(hour=0, minute=0, second=0, microsecond=0) + queryset = queryset.filter(start_time__gte=today_start) + else: + # Default to today only (using business timezone) + import pytz + business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC + now_business = timezone.now().astimezone(business_tz) + today = now_business.date() + queryset = queryset.filter(start_time__date=today) + + # Status filtering + status_filter = request.query_params.get('status') + if status_filter: + statuses = [s.strip().upper() for s in status_filter.split(',')] + queryset = queryset.filter(status__in=statuses) + + # Order by start time + queryset = queryset.order_by('start_time') + + # Prefetch for efficiency + queryset = queryset.select_related('service').prefetch_related('participants') + + serializer = JobListSerializer(queryset, many=True) + return Response({ + 'jobs': serializer.data, + 'count': queryset.count(), + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def job_detail_view(request, job_id): + """ + Get details of a specific job. + + GET /api/mobile/jobs/{job_id}/ + + Returns full job details including customer info, status history, etc. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + with schema_context(tenant.schema_name): + # Get the job and verify employee is assigned + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + serializer = JobDetailSerializer(job, context={'tenant': tenant, 'request': request}) + return Response(serializer.data) + + +# ============================================================================= +# Status Management Endpoints +# ============================================================================= + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def set_status_view(request, job_id): + """ + Update a job's status. + + POST /api/mobile/jobs/{job_id}/set_status/ + + Body: + { + "status": "IN_PROGRESS", + "notes": "Optional notes", + "latitude": 40.7128, + "longitude": -74.0060 + } + + Validates the status transition and records in history. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = SetStatusSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + with schema_context(tenant.schema_name): + # Get the job + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + # Perform the transition + status_machine = StatusMachine(tenant, user) + + try: + job = status_machine.transition( + event=job, + new_status=serializer.validated_data['status'], + notes=serializer.validated_data.get('notes', ''), + latitude=serializer.validated_data.get('latitude'), + longitude=serializer.validated_data.get('longitude'), + source='mobile_app', + ) + + # If job is completed, close any masked call sessions + if job.status == Event.Status.COMPLETED: + try: + call_service = TwilioFieldCallService(tenant) + call_service.close_session(job.id) + except Exception as e: + logger.warning(f"Error closing call session: {e}") + + response_serializer = JobDetailSerializer(job, context={'tenant': tenant}) + return Response({ + 'success': True, + 'job': response_serializer.data, + }) + + except StatusTransitionError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def start_en_route_view(request, job_id): + """ + Start traveling to a job. + + POST /api/mobile/jobs/{job_id}/start_en_route/ + + Body: + { + "latitude": 40.7128, + "longitude": -74.0060, + "send_customer_notification": true + } + + Changes status to EN_ROUTE and optionally notifies customer. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = StartEnRouteSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + with schema_context(tenant.schema_name): + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + status_machine = StatusMachine(tenant, user) + + try: + skip_notifications = not serializer.validated_data.get( + 'send_customer_notification', True + ) + + job = status_machine.transition( + event=job, + new_status=Event.Status.EN_ROUTE, + latitude=serializer.validated_data.get('latitude'), + longitude=serializer.validated_data.get('longitude'), + source='mobile_app', + skip_notifications=skip_notifications, + ) + + response_serializer = JobDetailSerializer(job, context={'tenant': tenant}) + return Response({ + 'success': True, + 'job': response_serializer.data, + 'tracking_enabled': True, + }) + + except StatusTransitionError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def reschedule_job_view(request, job_id): + """ + Reschedule a job (change start time and/or duration). + + POST /api/mobile/jobs/{job_id}/reschedule/ + + Body: + { + "start_time": "2024-01-15T10:30:00Z", // Optional: new start time + "end_time": "2024-01-15T11:30:00Z", // Optional: new end time + "duration_minutes": 60 // Optional: new duration + } + + Requires the user's linked resource to have user_can_edit_schedule=True. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = RescheduleJobSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + with schema_context(tenant.schema_name): + # Get the job + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + # Check if user has permission to edit schedule + user_resources = Resource.objects.filter(user=user) + can_edit = any(r.user_can_edit_schedule for r in user_resources) + + if not can_edit: + return Response( + {'error': 'You do not have permission to edit your schedule'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Update the job timing + start_time = serializer.validated_data.get('start_time') + end_time = serializer.validated_data.get('end_time') + duration_minutes = serializer.validated_data.get('duration_minutes') + + if start_time: + job.start_time = start_time + + if end_time: + job.end_time = end_time + elif duration_minutes and job.start_time: + # Calculate end_time from duration + job.end_time = job.start_time + timedelta(minutes=duration_minutes) + + job.save() + + logger.info( + f"Job {job_id} rescheduled by user {user.id}: " + f"start={job.start_time}, end={job.end_time}" + ) + + response_serializer = JobDetailSerializer(job, context={'tenant': tenant, 'request': request}) + return Response({ + 'success': True, + 'job': response_serializer.data, + }) + + +# ============================================================================= +# Location Tracking Endpoints +# ============================================================================= + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def location_update_view(request, job_id): + """ + Send a location update while en-route or in-progress. + + POST /api/mobile/jobs/{job_id}/location_update/ + + Body: + { + "latitude": 40.7128, + "longitude": -74.0060, + "accuracy": 10.5, + "altitude": 50.0, + "heading": 180.0, + "speed": 15.5, + "timestamp": "2024-01-15T10:30:00Z", + "battery_level": 0.75 + } + + Returns whether to continue tracking (stops on job completion). + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = LocationUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + with schema_context(tenant.schema_name): + # Verify job exists and user is assigned + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + # Check if tracking should continue + should_track = job.status in StatusMachine.TRACKING_STATUSES + + if not should_track: + return Response({ + 'success': False, + 'should_continue_tracking': False, + 'message': f'Location tracking not needed for status: {job.status}', + }) + + # Record the location update + location = EmployeeLocationUpdate.objects.create( + tenant=tenant, + employee=user, + event_id=job.id, + latitude=serializer.validated_data['latitude'], + longitude=serializer.validated_data['longitude'], + accuracy=serializer.validated_data.get('accuracy'), + altitude=serializer.validated_data.get('altitude'), + heading=serializer.validated_data.get('heading'), + speed=serializer.validated_data.get('speed'), + timestamp=serializer.validated_data['timestamp'], + battery_level=serializer.validated_data.get('battery_level'), + ) + + # Broadcast location update via WebSocket + # Find the resource linked to this user and broadcast to watchers + from schedule.models import Resource + from schedule.consumers import broadcast_resource_location_update + from asgiref.sync import async_to_sync + + user_resources = Resource.objects.filter(user=user) + for resource in user_resources: + location_data = { + 'latitude': float(location.latitude), + 'longitude': float(location.longitude), + 'accuracy': location.accuracy, + 'heading': location.heading, + 'speed': location.speed, + 'timestamp': location.timestamp.isoformat(), + } + active_job_data = { + 'id': job.id, + 'title': job.title, + 'status': job.status, + 'status_display': job.get_status_display(), + } + try: + async_to_sync(broadcast_resource_location_update)( + resource_id=resource.id, + location_data=location_data, + active_job=active_job_data + ) + except Exception as e: + logger.warning(f"Failed to broadcast location update: {e}") + + return Response({ + 'success': True, + 'should_continue_tracking': True, + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def location_route_view(request, job_id): + """ + Get the location history (route) for a job. + + GET /api/mobile/jobs/{job_id}/route/ + + Returns a list of locations for drawing the route on a map. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + with schema_context(tenant.schema_name): + # Verify job exists + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + # Get route data + route = EmployeeLocationUpdate.get_route_for_event( + tenant_id=tenant.id, + event_id=job.id, + limit=200 # More points for detailed route + ) + + # Convert Decimal to float for JSON serialization + for point in route: + point['latitude'] = float(point['latitude']) + point['longitude'] = float(point['longitude']) + + return Response({ + 'job_id': job.id, + 'route': route, + 'point_count': len(route), + }) + + +# ============================================================================= +# Calling and SMS Endpoints +# ============================================================================= + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def call_customer_view(request, job_id): + """ + Initiate a masked call to the customer. + + POST /api/mobile/jobs/{job_id}/call_customer/ + + The employee's phone will ring. When answered, they'll be connected + to the customer. Both parties see only the proxy number. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + with schema_context(tenant.schema_name): + # Verify job exists and user is assigned + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + try: + call_service = TwilioFieldCallService(tenant) + result = call_service.initiate_call( + event_id=job.id, + employee=user, + ) + + return Response(result) + + except TwilioFieldCallError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + logger.exception(f"Error initiating call: {e}") + return Response( + {'error': 'Failed to initiate call. Please try again.'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def send_sms_view(request, job_id): + """ + Send a masked SMS to the customer. + + POST /api/mobile/jobs/{job_id}/send_sms/ + + Body: + { + "message": "I'll be there in 10 minutes!" + } + + The customer sees the SMS from the proxy number. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = SendSMSSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + with schema_context(tenant.schema_name): + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + try: + call_service = TwilioFieldCallService(tenant) + result = call_service.send_sms( + event_id=job.id, + employee=user, + message=serializer.validated_data['message'], + ) + + return Response(result) + + except TwilioFieldCallError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + logger.exception(f"Error sending SMS: {e}") + return Response( + {'error': 'Failed to send SMS. Please try again.'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def call_history_view(request, job_id): + """ + Get call and SMS history for a job. + + GET /api/mobile/jobs/{job_id}/call_history/ + + Returns all calls and messages for this job. + """ + user = request.user + tenant = get_tenant_from_user(user) + + if not tenant: + return Response( + {'error': 'No business associated with your account'}, + status=status.HTTP_400_BAD_REQUEST + ) + + with schema_context(tenant.schema_name): + queryset = get_employee_jobs_queryset(user, tenant) + job = get_object_or_404(queryset, id=job_id) + + history = FieldCallLog.objects.filter( + tenant=tenant, + event_id=job.id + ).select_related('employee').order_by('-initiated_at')[:50] + + serializer = CallHistorySerializer(history, many=True) + return Response({ + 'job_id': job.id, + 'history': serializer.data, + }) + + +# ============================================================================= +# Twilio Webhook Endpoints +# ============================================================================= + +@csrf_exempt +@api_view(['POST']) +@permission_classes([AllowAny]) +def twilio_voice_webhook(request, session_id): + """ + Twilio webhook for handling voice calls. + + POST /api/mobile/twilio/voice/{session_id}/ + + Called by Twilio when a call is initiated or received. + Returns TwiML to route the call. + """ + from smoothschedule.field_mobile.services.twilio_calls import handle_incoming_call + + from_number = request.data.get('From', '') + twiml = handle_incoming_call(session_id, from_number) + + return HttpResponse(twiml, content_type='application/xml') + + +@csrf_exempt +@api_view(['POST']) +@permission_classes([AllowAny]) +def twilio_voice_status_webhook(request, session_id): + """ + Twilio webhook for call status updates. + + POST /api/mobile/twilio/voice-status/{session_id}/ + + Updates the FieldCallLog with call status. + """ + call_sid = request.data.get('CallSid', '') + call_status = request.data.get('CallStatus', '') + duration = request.data.get('CallDuration', 0) + + if call_sid: + try: + call_log = FieldCallLog.objects.get(twilio_call_sid=call_sid) + + # Map Twilio status to our status + status_map = { + 'queued': FieldCallLog.Status.INITIATED, + 'ringing': FieldCallLog.Status.RINGING, + 'in-progress': FieldCallLog.Status.IN_PROGRESS, + 'completed': FieldCallLog.Status.COMPLETED, + 'busy': FieldCallLog.Status.BUSY, + 'no-answer': FieldCallLog.Status.NO_ANSWER, + 'failed': FieldCallLog.Status.FAILED, + 'canceled': FieldCallLog.Status.CANCELED, + } + + call_log.status = status_map.get(call_status, FieldCallLog.Status.COMPLETED) + + if call_status == 'completed': + call_log.ended_at = timezone.now() + call_log.duration_seconds = int(duration) + elif call_status == 'in-progress': + call_log.answered_at = timezone.now() + + call_log.save() + + # Update session voice usage + if call_log.masked_session and duration: + session = call_log.masked_session + session.voice_seconds += int(duration) + session.save(update_fields=['voice_seconds', 'updated_at']) + + except FieldCallLog.DoesNotExist: + logger.warning(f"FieldCallLog not found for call SID: {call_sid}") + + return HttpResponse('', status=200) + + +@csrf_exempt +@api_view(['POST']) +@permission_classes([AllowAny]) +def twilio_sms_webhook(request, session_id): + """ + Twilio webhook for incoming SMS. + + POST /api/mobile/twilio/sms/{session_id}/ + + Forwards the SMS to the appropriate party. + """ + from smoothschedule.field_mobile.services.twilio_calls import handle_incoming_sms + + from_number = request.data.get('From', '') + body = request.data.get('Body', '') + + handle_incoming_sms(session_id, from_number, body) + + # Return empty TwiML response + return HttpResponse('', + content_type='application/xml') + + +@csrf_exempt +@api_view(['POST']) +@permission_classes([AllowAny]) +def twilio_sms_status_webhook(request, session_id): + """ + Twilio webhook for SMS status updates. + + POST /api/mobile/twilio/sms-status/{session_id}/ + """ + # SMS status updates are less critical, just log them + message_sid = request.data.get('MessageSid', '') + message_status = request.data.get('MessageStatus', '') + + if message_sid and message_status: + logger.debug(f"SMS {message_sid} status: {message_status}") + + return HttpResponse('', status=200) diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py index 5a023b7..7a6d7f7 100644 --- a/smoothschedule/smoothschedule/users/api_views.py +++ b/smoothschedule/smoothschedule/users/api_views.py @@ -83,9 +83,8 @@ def login_view(request): }) # No MFA required or device is trusted - complete login - # Create auth token - Token.objects.filter(user=user).delete() - token = Token.objects.create(user=user) + # Use get_or_create to allow multi-device logins with the same token + token, created = Token.objects.get_or_create(user=user) # Update last login IP client_ip = _get_client_ip(request) @@ -154,6 +153,19 @@ def current_user_view(request): } frontend_role = role_mapping.get(user.role.lower(), user.role.lower()) + # Get linked resource info for staff users + linked_resource_id = None + can_edit_schedule = False + if user.tenant and user.role == User.Role.TENANT_STAFF: + try: + with schema_context(user.tenant.schema_name): + linked_resource = Resource.objects.filter(user=user).first() + if linked_resource: + linked_resource_id = linked_resource.id + can_edit_schedule = linked_resource.user_can_edit_schedule + except Exception: + pass + user_data = { 'id': user.id, 'username': user.username, @@ -171,6 +183,8 @@ def current_user_view(request): 'permissions': user.permissions, 'can_invite_staff': user.can_invite_staff(), 'can_access_tickets': user.can_access_tickets(), + 'can_edit_schedule': can_edit_schedule, + 'linked_resource_id': linked_resource_id, 'quota_overages': quota_overages, } @@ -299,6 +313,19 @@ def _get_user_data(user): } frontend_role = role_mapping.get(user.role.lower(), user.role.lower()) + # Get linked resource info for staff users + linked_resource_id = None + can_edit_schedule = False + if user.tenant and user.role == User.Role.TENANT_STAFF: + try: + with schema_context(user.tenant.schema_name): + linked_resource = Resource.objects.filter(user=user).first() + if linked_resource: + linked_resource_id = linked_resource.id + can_edit_schedule = linked_resource.user_can_edit_schedule + except Exception: + pass + return { 'id': user.id, 'username': user.username, @@ -316,6 +343,8 @@ def _get_user_data(user): 'permissions': user.permissions, 'can_invite_staff': user.can_invite_staff(), 'can_access_tickets': user.can_access_tickets(), + 'can_edit_schedule': can_edit_schedule, + 'linked_resource_id': linked_resource_id, } @@ -773,8 +802,7 @@ def accept_invitation_view(request, token): ) # Create auth token for immediate login - Token.objects.filter(user=user).delete() - auth_token = Token.objects.create(user=user) + auth_token, _ = Token.objects.get_or_create(user=user) response_data = { 'access': auth_token.key, @@ -1012,7 +1040,7 @@ def signup_view(request): ) # 6. Generate Token - token = Token.objects.create(user=user) + token, _ = Token.objects.get_or_create(user=user) # 7. Send Verification Email (optional, but good practice) # We can reuse send_verification_email logic or call it here diff --git a/smoothschedule/tickets/middleware.py b/smoothschedule/tickets/middleware.py index 01cc464..9a0ac69 100644 --- a/smoothschedule/tickets/middleware.py +++ b/smoothschedule/tickets/middleware.py @@ -1,15 +1,27 @@ from channels.db import database_sync_to_async from django.contrib.auth.models import AnonymousUser from rest_framework.authtoken.models import Token -from django.db import close_old_connections +from django.db import close_old_connections, connection @database_sync_to_async def get_user(token_key): + """ + Look up user by token key. + + IMPORTANT: Tokens are stored in the public schema, so we need to + explicitly query from public schema in multi-tenant setup. + """ try: - token = Token.objects.select_related('user').get(key=token_key) - return token.user + # Use public schema for token lookup (tokens are in shared apps) + from django_tenants.utils import schema_context + with schema_context('public'): + token = Token.objects.select_related('user').get(key=token_key) + return token.user except Token.DoesNotExist: return AnonymousUser() + except Exception as e: + print(f"TokenAuthMiddleware: Error looking up token: {e}", flush=True) + return AnonymousUser() class TokenAuthMiddleware: """