+ {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')}
+
+ );
+};
+
+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')}
+
+ {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.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.'
+ )}
+
+ {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')}
+
+
+
{t('staffHelp.availability.step1', 'Click "Add Time Block" button')}
+
{t('staffHelp.availability.step2', 'Select the date and time range')}
+
{t('staffHelp.availability.step3', 'Add an optional reason (e.g., "Vacation", "Doctor appointment")')}
+
{t('staffHelp.availability.step4', 'Choose if it repeats (one-time, weekly, etc.)')}
+
{t('staffHelp.availability.step5', 'Save your time block')}
+
+
+
+
+ {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:
"""