From 49f1c7964fddb09dfb6a81d7175af321f9ce27cd Mon Sep 17 00:00:00 2001 From: Ari Spokony Date: Sat, 4 Apr 2026 19:03:23 -0400 Subject: [PATCH 1/7] inital frontend from previous pr --- clients/mobile/app/(tabs)/tasks.tsx | 229 +++++++++++++++--- clients/mobile/app/_layout.tsx | 71 +++--- .../components/tasks/active-filter-chips.tsx | 13 +- clients/mobile/components/tasks/task-card.tsx | 101 +++++--- .../tasks/task-completion-modal.tsx | 30 +++ .../components/tasks/task-detail-sheet.tsx | 115 +++++++++ .../components/tasks/task-filter-sheet.tsx | 160 ++++++++++++ clients/mobile/components/tasks/task-list.tsx | 65 ++++- .../mobile/components/tasks/tasks-header.tsx | 55 +++-- clients/mobile/constants/tasks.ts | 30 +++ clients/mobile/hooks/use-tasks-feed.ts | 81 +++++++ clients/mobile/package-lock.json | 40 +++ clients/mobile/package.json | 3 +- clients/mobile/types/tasks.ts | 65 +++++ clients/shared/src/api/client.ts | 18 +- clients/shared/src/api/config.ts | 4 + clients/shared/src/api/endpoints.ts | 7 +- clients/shared/src/index.ts | 2 + clients/web/src/routes/__root.tsx | 8 +- 19 files changed, 963 insertions(+), 134 deletions(-) create mode 100644 clients/mobile/components/tasks/task-completion-modal.tsx create mode 100644 clients/mobile/components/tasks/task-detail-sheet.tsx create mode 100644 clients/mobile/components/tasks/task-filter-sheet.tsx create mode 100644 clients/mobile/hooks/use-tasks-feed.ts create mode 100644 clients/mobile/types/tasks.ts diff --git a/clients/mobile/app/(tabs)/tasks.tsx b/clients/mobile/app/(tabs)/tasks.tsx index b078eb537..85598e299 100644 --- a/clients/mobile/app/(tabs)/tasks.tsx +++ b/clients/mobile/app/(tabs)/tasks.tsx @@ -1,54 +1,215 @@ -import { useState } from "react"; -import { View } from "react-native"; +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { ActiveFilterChips } from "@/components/tasks/active-filter-chips"; +import { + ActiveFilterChips, + type ActiveFilterChip, +} from "@/components/tasks/active-filter-chips"; import { TabBar } from "@/components/tasks/tab-bar"; +import { TaskCompletionModal } from "@/components/tasks/task-completion-modal"; +import { TaskDetailSheet } from "@/components/tasks/task-detail-sheet"; +import { TaskFilterSheet } from "@/components/tasks/task-filter-sheet"; import { TaskList } from "@/components/tasks/task-list"; import { TasksHeader } from "@/components/tasks/tasks-header"; -import { TAB, TabName, TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; -import { myTasks, unassignedTasks } from "@/data/mockTasks"; - -const tabConfigs: Record< - TabName, - { - tasks: typeof myTasks; - variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; - showFilters: boolean; +import { + TAB, + TASK_ASSIGNMENT_STATE, + TASK_FILTER_DEPARTMENTS, + TASK_FILTER_PRIORITIES, + TASK_FILTER_STATUS_MY, + TASK_FILTER_STATUS_UNASSIGNED, + type TabName, +} from "@/constants/tasks"; +import { useTaskMutations, useTasksFeed } from "@/hooks/use-tasks-feed"; +import type { Task, TasksFilterState } from "@/types/tasks"; + +function filterChips( + tab: TabName, + filters: TasksFilterState, +): ActiveFilterChip[] { + const out: ActiveFilterChip[] = []; + if (filters.department) { + const d = TASK_FILTER_DEPARTMENTS.find( + (x) => x.value === filters.department, + ); + out.push({ + field: "department", + label: `Dept: ${d?.label ?? filters.department}`, + }); + } + if (filters.priority) { + const p = TASK_FILTER_PRIORITIES.find((x) => x.value === filters.priority); + out.push({ + field: "priority", + label: `Priority: ${p?.label ?? filters.priority}`, + }); + } + if (filters.status) { + const pool = + tab === TAB.MY_TASKS + ? TASK_FILTER_STATUS_MY + : TASK_FILTER_STATUS_UNASSIGNED; + const s = pool.find((x) => x.value === filters.status); + out.push({ + field: "status", + label: `Status: ${s?.label ?? filters.status}`, + }); } -> = { - [TAB.MY_TASKS]: { - tasks: myTasks, - variant: TASK_ASSIGNMENT_STATE.ASSIGNED, - showFilters: false, - }, - [TAB.UNASSIGNED]: { - tasks: unassignedTasks, - variant: TASK_ASSIGNMENT_STATE.UNASSIGNED, - showFilters: true, - }, -}; + return out; +} export default function TasksScreen() { const [activeTab, setActiveTab] = useState(TAB.MY_TASKS); - const currentTab = tabConfigs[activeTab]; + const [filters, setFilters] = useState({}); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [completeOpen, setCompleteOpen] = useState(false); + + const filterSheetRef = useRef(null); + const detailSheetRef = useRef(null); + + const { + flatTasks, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isRefetching, + error, + refetch, + } = useTasksFeed(activeTab, filters); + + const { patchStatus, claimTask, dropTask } = useTaskMutations(); + const mutating = + patchStatus.isPending || claimTask.isPending || dropTask.isPending; + + const displayed = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return flatTasks; + return flatTasks.filter( + (t) => + t.title.toLowerCase().includes(q) || + (t.description?.toLowerCase().includes(q) ?? false), + ); + }, [flatTasks, searchQuery]); + + const variant = + activeTab === TAB.MY_TASKS + ? TASK_ASSIGNMENT_STATE.ASSIGNED + : TASK_ASSIGNMENT_STATE.UNASSIGNED; + + const chips = useMemo( + () => filterChips(activeTab, filters), + [activeTab, filters], + ); + + const openDetail = useCallback((t: Task) => { + setSelected(t); + detailSheetRef.current?.present(); + }, []); + + const handleStart = useCallback( + async (id: string) => { + await patchStatus.mutateAsync({ id, status: "in progress" }); + detailSheetRef.current?.dismiss(); + }, + [patchStatus], + ); + + const handleComplete = useCallback( + async (id: string) => { + await patchStatus.mutateAsync({ id, status: "completed" }); + detailSheetRef.current?.dismiss(); + setCompleteOpen(true); + }, + [patchStatus], + ); + + const handleClaim = useCallback( + async (id: string) => { + await claimTask.mutateAsync(id); + detailSheetRef.current?.dismiss(); + }, + [claimTask], + ); + + const handleDrop = useCallback( + async (id: string) => { + await dropTask.mutateAsync(id); + detailSheetRef.current?.dismiss(); + }, + [dropTask], + ); return ( - + setSearchOpen((v) => !v)} + searchQuery={searchQuery} + onSearchQuery={setSearchQuery} + onOpenFilters={() => filterSheetRef.current?.present()} + /> - {currentTab.showFilters && ( + {chips.length > 0 ? ( {}} - onClearAll={() => {}} + filters={chips} + onRemoveFilter={(field) => + setFilters((f) => ({ ...f, [field]: undefined })) + } + onClearAll={() => setFilters({})} /> - )} + ) : null} - + {error ? ( + + {(error as Error).message || "Failed to load tasks"} + + ) : null} + {isPending && !flatTasks.length ? ( + Loading… + ) : ( + { + if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }} + listFooter={isFetchingNextPage} + refreshing={isRefetching} + onRefresh={() => void refetch()} + /> + )} + + + + setCompleteOpen(false)} + /> ); } diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index 44af1c301..afaf8f716 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -1,5 +1,8 @@ +import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import "react-native-reanimated"; @@ -33,10 +36,15 @@ export const unstable_settings = { // Component to configure auth provider and the api base url function AppConfigurator() { const { getToken } = useAuth(); - setConfig({ - API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL ?? "", - getToken, - }); + useEffect(() => { + setConfig({ + API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL ?? "", + getToken, + ROOMS_HOTEL_ID: + process.env.EXPO_PUBLIC_ROOMS_HOTEL_ID || + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", + }); + }, [getToken]); return null; } @@ -44,29 +52,36 @@ export default function RootLayout() { const colorScheme = useColorScheme(); return ( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); } diff --git a/clients/mobile/components/tasks/active-filter-chips.tsx b/clients/mobile/components/tasks/active-filter-chips.tsx index c6b8ee592..1c6b108fc 100644 --- a/clients/mobile/components/tasks/active-filter-chips.tsx +++ b/clients/mobile/components/tasks/active-filter-chips.tsx @@ -1,9 +1,14 @@ import Feather from "@expo/vector-icons/Feather"; import { Pressable, Text, View } from "react-native"; +export type ActiveFilterChip = { + field: "department" | "priority" | "status"; + label: string; +}; + interface ActiveFilterChipsProps { - filters: { label: string; value: string }[]; - onRemoveFilter: (value: string) => void; + filters: ActiveFilterChip[]; + onRemoveFilter: (field: ActiveFilterChip["field"]) => void; onClearAll: () => void; } @@ -16,11 +21,11 @@ export function ActiveFilterChips({ {filters.map((filter) => ( {filter.label} - onRemoveFilter(filter.value)}> + onRemoveFilter(filter.field)}> diff --git a/clients/mobile/components/tasks/task-card.tsx b/clients/mobile/components/tasks/task-card.tsx index ebdfeefae..8b022a80d 100644 --- a/clients/mobile/components/tasks/task-card.tsx +++ b/clients/mobile/components/tasks/task-card.tsx @@ -2,46 +2,65 @@ import Feather from "@expo/vector-icons/Feather"; import { Pressable, Text, View } from "react-native"; import { TaskBadge } from "@/components/tasks/task-badge"; -import type { Task } from "@/data/mockTasks"; import { TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; +import type { Task } from "@/types/tasks"; interface TaskCardProps { task: Task; variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; isExpanded: boolean; + busy: boolean; + onOpenDetail: () => void; + onPrimary: () => void; } function DotSeparator() { return ; } -export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { +export function TaskCard({ + task, + variant, + isExpanded, + busy, + onOpenDetail, + onPrimary, +}: TaskCardProps) { const isAssigned = variant === TASK_ASSIGNMENT_STATE.ASSIGNED; if (isAssigned && isExpanded) { return ( - {task.title} - - {task.priority} - - {task.department} - {task.dueTime && ( - <> - - {task.dueTime} - - )} - - {task.description && ( - {task.description} - )} - {}} - className="bg-blue-600 rounded-lg py-3 w-full items-center mt-3" - > - Start + + {task.title} + + {task.priority} + + {task.department} + {task.dueTime ? ( + <> + + {task.dueTime} + + ) : null} + + {task.description ? ( + + {task.description} + + ) : null} + {(task.status === "assigned" || task.status === "in progress") && ( + + + {task.status === "in progress" ? "Mark done" : "Start"} + + + )} ); } @@ -49,14 +68,14 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { if (isAssigned && !isExpanded) { return ( - + {task.title} - - {}} className="p-1"> + + @@ -66,17 +85,22 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { if (!isAssigned && isExpanded) { return ( - {task.title} - - - - - - {task.description && ( - {task.description} - )} + + {task.title} + + + + + + {task.description ? ( + + {task.description} + + ) : null} + {}} + disabled={busy} + onPress={onPrimary} className="bg-white border border-gray-300 rounded-lg py-3 w-full items-center mt-3" > Claim Task @@ -85,18 +109,17 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { ); } - // Compact unassigned return ( - + {task.title} - - {}} className="p-1"> + + diff --git a/clients/mobile/components/tasks/task-completion-modal.tsx b/clients/mobile/components/tasks/task-completion-modal.tsx new file mode 100644 index 000000000..be9c64272 --- /dev/null +++ b/clients/mobile/components/tasks/task-completion-modal.tsx @@ -0,0 +1,30 @@ +import { Modal, Pressable, Text, View } from "react-native"; + +type TaskCompletionModalProps = { + visible: boolean; + onClose: () => void; +}; + +export function TaskCompletionModal({ + visible, + onClose, +}: TaskCompletionModalProps) { + return ( + + + + Task completed + + Nice work. Manager notes are not synced yet. + + + Done + + + + + ); +} diff --git a/clients/mobile/components/tasks/task-detail-sheet.tsx b/clients/mobile/components/tasks/task-detail-sheet.tsx new file mode 100644 index 000000000..c02ceaa03 --- /dev/null +++ b/clients/mobile/components/tasks/task-detail-sheet.tsx @@ -0,0 +1,115 @@ +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import React, { useCallback } from "react"; +import { Pressable, Text, View } from "react-native"; + +import { TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; +import type { Task } from "@/types/tasks"; + +type TaskDetailSheetProps = { + sheetRef: React.RefObject; + task: Task | null; + variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; + onStart: (id: string) => void; + onComplete: (id: string) => void; + onClaim: (id: string) => void; + onDrop: (id: string) => void; + busy: boolean; +}; + +export function TaskDetailSheet({ + sheetRef, + task, + variant, + onStart, + onComplete, + onClaim, + onDrop, + busy, +}: TaskDetailSheetProps) { + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + const assigned = variant === TASK_ASSIGNMENT_STATE.ASSIGNED; + + return ( + + + {!task ? null : ( + <> + {task.title} + + {task.priority} · {task.department} · {task.location} + + {task.dueTime ? ( + + Due {task.dueTime} + + ) : null} + {task.description ? ( + + {task.description} + + ) : null} + + + {assigned && task.status === "assigned" ? ( + onStart(task.id)} + className="bg-blue-600 rounded-xl py-3 items-center" + > + Start + + ) : null} + {assigned && task.status === "in progress" ? ( + onComplete(task.id)} + className="bg-blue-600 rounded-xl py-3 items-center" + > + Mark done + + ) : null} + {assigned && + (task.status === "assigned" || task.status === "in progress") ? ( + onDrop(task.id)} + className="border border-gray-300 rounded-xl py-3 items-center" + > + Drop task + + ) : null} + {!assigned ? ( + onClaim(task.id)} + className="bg-white border border-gray-300 rounded-xl py-3 items-center" + > + Claim task + + ) : null} + + + )} + + + ); +} diff --git a/clients/mobile/components/tasks/task-filter-sheet.tsx b/clients/mobile/components/tasks/task-filter-sheet.tsx new file mode 100644 index 000000000..a2c711768 --- /dev/null +++ b/clients/mobile/components/tasks/task-filter-sheet.tsx @@ -0,0 +1,160 @@ +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import React, { useCallback, useMemo, useState } from "react"; +import { Pressable, Text, View } from "react-native"; + +import { + TASK_FILTER_DEPARTMENTS, + TASK_FILTER_PRIORITIES, + TASK_FILTER_STATUS_MY, + TASK_FILTER_STATUS_UNASSIGNED, + TAB, + type TabName, +} from "@/constants/tasks"; +import type { TasksFilterState } from "@/types/tasks"; + +type TaskFilterSheetProps = { + sheetRef: React.RefObject; + activeTab: TabName; + applied: TasksFilterState; + onApply: (next: TasksFilterState) => void; +}; + +function Chip({ + label, + selected, + onPress, +}: { + label: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +export function TaskFilterSheet({ + sheetRef, + activeTab, + applied, + onApply, +}: TaskFilterSheetProps) { + const [draft, setDraft] = useState(applied); + + const statusOptions = useMemo( + () => + activeTab === TAB.MY_TASKS + ? TASK_FILTER_STATUS_MY + : TASK_FILTER_STATUS_UNASSIGNED, + [activeTab], + ); + + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + const openSync = () => setDraft({ ...applied }); + + return ( + { + if (i >= 0) openSync(); + }} + > + + Filters + + + Department + + + {TASK_FILTER_DEPARTMENTS.map((d) => ( + + setDraft((s) => ({ + ...s, + department: s.department === d.value ? undefined : d.value, + })) + } + /> + ))} + + + + Priority + + + {TASK_FILTER_PRIORITIES.map((p) => ( + + setDraft((s) => ({ + ...s, + priority: s.priority === p.value ? undefined : p.value, + })) + } + /> + ))} + + + Status + + {statusOptions.map((s) => ( + + setDraft((prev) => ({ + ...prev, + status: prev.status === s.value ? undefined : s.value, + })) + } + /> + ))} + + + { + onApply(draft); + sheetRef.current?.dismiss(); + }} + className="bg-blue-600 rounded-xl py-4 items-center" + > + Apply + + + + ); +} diff --git a/clients/mobile/components/tasks/task-list.tsx b/clients/mobile/components/tasks/task-list.tsx index 1de565104..d9add5f7d 100644 --- a/clients/mobile/components/tasks/task-list.tsx +++ b/clients/mobile/components/tasks/task-list.tsx @@ -1,21 +1,64 @@ -import { FlatList, ListRenderItem } from "react-native"; +import { + ActivityIndicator, + FlatList, + ListRenderItem, + RefreshControl, + View, +} from "react-native"; import { TaskCard } from "@/components/tasks/task-card"; -import type { Task } from "@/data/mockTasks"; import { TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; +import type { Task } from "@/types/tasks"; interface TaskListProps { tasks: Task[]; variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; + onOpenDetail: (task: Task) => void; + onStart: (id: string) => void; + onComplete: (id: string) => void; + onClaim: (id: string) => void; + isMutating: boolean; + onEndReached: () => void; + listFooter: boolean; + refreshing: boolean; + onRefresh: () => void; } -export function TaskList({ tasks, variant }: TaskListProps) { +export function TaskList({ + tasks, + variant, + onOpenDetail, + onStart, + onComplete, + onClaim, + isMutating, + onEndReached, + listFooter, + refreshing, + onRefresh, +}: TaskListProps) { const renderItem: ListRenderItem = ({ item, index }) => { const isExpanded = variant === TASK_ASSIGNMENT_STATE.ASSIGNED ? index === 0 - : item.priority === "High"; - return ; + : item.priority.toLowerCase() === "high"; + return ( + onOpenDetail(item)} + onPrimary={() => { + if (variant === TASK_ASSIGNMENT_STATE.UNASSIGNED) { + onClaim(item.id); + return; + } + if (item.status === "assigned") onStart(item.id); + else if (item.status === "in progress") onComplete(item.id); + }} + /> + ); }; return ( @@ -25,6 +68,18 @@ export function TaskList({ tasks, variant }: TaskListProps) { renderItem={renderItem} contentContainerClassName="px-[5vw] py-4 gap-4" showsVerticalScrollIndicator={false} + onEndReached={onEndReached} + onEndReachedThreshold={0.35} + refreshControl={ + + } + ListFooterComponent={ + listFooter ? ( + + + + ) : null + } /> ); } diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index d21ed55fe..f15ac37ba 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -1,21 +1,48 @@ import Feather from "@expo/vector-icons/Feather"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, TextInput, View } from "react-native"; -export function TasksHeader() { +type TasksHeaderProps = { + searchOpen: boolean; + onToggleSearch: () => void; + searchQuery: string; + onSearchQuery: (q: string) => void; + onOpenFilters: () => void; +}; + +export function TasksHeader({ + searchOpen, + onToggleSearch, + searchQuery, + onSearchQuery, + onOpenFilters, +}: TasksHeaderProps) { return ( - - Tasks - - {}}> - - - {}}> - - - {}}> - - + + + Tasks + + + + + + + + {}}> + + + + {searchOpen ? ( + + ) : null} ); } diff --git a/clients/mobile/constants/tasks.ts b/clients/mobile/constants/tasks.ts index d5f229d45..053cde70d 100644 --- a/clients/mobile/constants/tasks.ts +++ b/clients/mobile/constants/tasks.ts @@ -12,3 +12,33 @@ export const TASK_ASSIGNMENT_STATE = { export type TaskAssignmentState = (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; + +/** API `tab` query value */ +export function tabToApi(tab: TabName): "my" | "unassigned" { + return tab === TAB.MY_TASKS ? "my" : "unassigned"; +} + +export const TASK_FILTER_DEPARTMENTS = [ + { label: "Housekeeping", value: "Housekeeping" }, + { label: "Room Service", value: "Room Service" }, + { label: "Maintenance", value: "Maintenance" }, + { label: "Front Desk", value: "Front Desk" }, +] as const; + +export const TASK_FILTER_PRIORITIES = [ + { label: "Low", value: "low" }, + { label: "Medium", value: "medium" }, + { label: "High", value: "high" }, +] as const; + +/** Status values for `my` tab (API enum). */ +export const TASK_FILTER_STATUS_MY = [ + { label: "Assigned", value: "assigned" }, + { label: "In progress", value: "in progress" }, + { label: "Completed", value: "completed" }, +] as const; + +/** Status values for unassigned tab. */ +export const TASK_FILTER_STATUS_UNASSIGNED = [ + { label: "Pending", value: "pending" }, +] as const; diff --git a/clients/mobile/hooks/use-tasks-feed.ts b/clients/mobile/hooks/use-tasks-feed.ts new file mode 100644 index 000000000..d31d00467 --- /dev/null +++ b/clients/mobile/hooks/use-tasks-feed.ts @@ -0,0 +1,81 @@ +import { + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { API_ENDPOINTS, useAPIClient } from "@shared"; + +import { TAB, type TabName, tabToApi } from "@/constants/tasks"; +import type { + BackendTask, + CursorPage, + Task, + TasksFilterState, +} from "@/types/tasks"; +import { mapBackendTask } from "@/types/tasks"; + +function buildTaskParams( + tab: TabName, + filters: TasksFilterState, + cursor: string, +): Record { + const params: Record = { + tab: tabToApi(tab), + limit: "20", + }; + if (cursor) params.cursor = cursor; + if (filters.department) params.department = filters.department; + if (filters.priority) params.priority = filters.priority; + if (filters.status) params.status = filters.status; + return params; +} + +export function useTasksFeed(tab: TabName, filters: TasksFilterState) { + const client = useAPIClient(); + + const query = useInfiniteQuery({ + queryKey: ["tasks-feed", tab, filters] as const, + initialPageParam: "", + queryFn: async ({ pageParam }) => { + const params = buildTaskParams(tab, filters, pageParam); + return client.get>(API_ENDPOINTS.TASKS, params); + }, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + }); + + const flatTasks: Task[] = (query.data?.pages ?? []).flatMap((p) => + (p.items ?? []).map(mapBackendTask), + ); + + return { ...query, flatTasks }; +} + +export function useTaskMutations() { + const client = useAPIClient(); + const qc = useQueryClient(); + + const invalidate = () => qc.invalidateQueries({ queryKey: ["tasks-feed"] }); + + const patchStatus = useMutation({ + mutationFn: async ({ id, status }: { id: string; status: string }) => { + await client.patch(API_ENDPOINTS.task(id), { status }); + }, + onSettled: invalidate, + }); + + const claimTask = useMutation({ + mutationFn: async (id: string) => { + await client.post(API_ENDPOINTS.taskClaim(id), {}); + }, + onSettled: invalidate, + }); + + const dropTask = useMutation({ + mutationFn: async (id: string) => { + await client.post(API_ENDPOINTS.taskDrop(id), {}); + }, + onSettled: invalidate, + }); + + return { patchStatus, claimTask, dropTask }; +} diff --git a/clients/mobile/package-lock.json b/clients/mobile/package-lock.json index 110d04881..97b76e65f 100644 --- a/clients/mobile/package-lock.json +++ b/clients/mobile/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@clerk/clerk-expo": "^2.19.23", "@expo/vector-icons": "^15.0.3", + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-picker/picker": "^2.11.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -2409,6 +2410,45 @@ "integrity": "sha512-DHHC01EJ1p70Q0z/ZFRBIY8NDnmfKccQoyoM84Tgb6omLMat6jivCdf272Y8k3nf4Lzdin/Y4R9q8uFtU0GbnA==", "license": "MIT" }, + "node_modules/@gorhom/bottom-sheet": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz", + "integrity": "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA==", + "license": "MIT", + "dependencies": { + "@gorhom/portal": "1.0.14", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-native": "*", + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.16.1", + "react-native-reanimated": ">=3.16.0 || >=4.0.0-" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-native": { + "optional": true + } + } + }, + "node_modules/@gorhom/portal": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", + "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, diff --git a/clients/mobile/package.json b/clients/mobile/package.json index f07dd9ab5..d48ca61d8 100644 --- a/clients/mobile/package.json +++ b/clients/mobile/package.json @@ -17,6 +17,7 @@ "dependencies": { "@clerk/clerk-expo": "^2.19.23", "@expo/vector-icons": "^15.0.3", + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-picker/picker": "^2.11.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -58,9 +59,9 @@ "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", - "prettier": "^3.5.3", "jest": "~29.7.0", "jest-expo": "^54.0.16", + "prettier": "^3.5.3", "react-native-dotenv": "^3.4.11", "react-test-renderer": "^19.1.0", "tailwindcss": "^3.4.19", diff --git a/clients/mobile/types/tasks.ts b/clients/mobile/types/tasks.ts new file mode 100644 index 000000000..e1a56ee42 --- /dev/null +++ b/clients/mobile/types/tasks.ts @@ -0,0 +1,65 @@ +/** API row from GET /tasks (matches backend models.Task). */ +export type BackendTask = { + id: string; + title: string; + priority: string; + department?: string | null; + location: string; + description?: string | null; + due_time?: string | null; + status: string; + is_assigned: boolean; +}; + +export type CursorPage = { + /** Backend may send `null` for empty lists (Go nil slice JSON). */ + items: T[] | null; + next_cursor: string | null; + has_more: boolean; +}; + +/** Normalized task for list/card UI. */ +export type Task = { + id: string; + title: string; + priority: string; + department: string; + location: string; + description?: string; + dueTime?: string; + status: string; + isAssigned: boolean; +}; + +export type TasksFilterState = { + department?: string; + priority?: string; + status?: string; +}; + +export function mapBackendTask(t: BackendTask): Task { + const cap = (s: string) => + s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s; + let due: string | undefined; + if (t.due_time) { + try { + due = new Date(t.due_time).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + } catch { + due = t.due_time; + } + } + return { + id: t.id, + title: t.title, + priority: cap(t.priority), + department: t.department?.trim() || "—", + location: t.location, + description: t.description ?? undefined, + dueTime: due, + status: t.status, + isAssigned: t.is_assigned, + }; +} diff --git a/clients/shared/src/api/client.ts b/clients/shared/src/api/client.ts index 648ae378f..5ff378794 100644 --- a/clients/shared/src/api/client.ts +++ b/clients/shared/src/api/client.ts @@ -8,7 +8,6 @@ export const createRequest = ( getToken: () => Promise, baseUrl: string, ) => { - const hardCodedHotelId = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" return async (config: RequestConfig): Promise => { let fullUrl = `${baseUrl}${config.url}`; if (config.params && Object.keys(config.params).length > 0) { @@ -18,15 +17,20 @@ export const createRequest = ( try { const token = await getToken(); + const { ROOMS_HOTEL_ID } = getConfig(); + const headers: Record = { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + ...config.headers, + }; + const hotel = ROOMS_HOTEL_ID?.trim(); + if (hotel) { + headers["X-Hotel-ID"] = hotel; + } const response = await fetch(fullUrl, { method: config.method, - headers: { - "Content-Type": "application/json", - ...(token && { Authorization: `Bearer ${token}` }), - "X-Hotel-ID": hardCodedHotelId, - ...config.headers, - }, + headers, body: config.data ? JSON.stringify(config.data) : undefined, signal: config.signal, }); diff --git a/clients/shared/src/api/config.ts b/clients/shared/src/api/config.ts index fee2aae85..5413d7fbd 100644 --- a/clients/shared/src/api/config.ts +++ b/clients/shared/src/api/config.ts @@ -8,6 +8,10 @@ export type Config = { API_BASE_URL: string getToken: () => Promise + /** + * When set, sent as X-Hotel-ID (required by /rooms). Staff /tasks uses JWT + user hotel only. + */ + ROOMS_HOTEL_ID?: string } let config: Config | null = null diff --git a/clients/shared/src/api/endpoints.ts b/clients/shared/src/api/endpoints.ts index 6abd58fdc..a333d2151 100644 --- a/clients/shared/src/api/endpoints.ts +++ b/clients/shared/src/api/endpoints.ts @@ -1 +1,6 @@ -// API endpoint constants +export const API_ENDPOINTS = { + TASKS: "/tasks", + task: (id: string) => `/tasks/${id}`, + taskClaim: (id: string) => `/tasks/${id}/claim`, + taskDrop: (id: string) => `/tasks/${id}/drop`, +} as const; diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index c19029715..58f8b4cc2 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -5,6 +5,8 @@ export type { Config } from "./api/config"; // config functions export { setConfig, getConfig } from "./api/config"; +export { API_ENDPOINTS } from "./api/endpoints"; +export { useAPIClient, getBaseUrl } from "./api/client"; // Generated Types - Models export type { diff --git a/clients/web/src/routes/__root.tsx b/clients/web/src/routes/__root.tsx index db64dc9a1..54feedcc0 100644 --- a/clients/web/src/routes/__root.tsx +++ b/clients/web/src/routes/__root.tsx @@ -61,7 +61,13 @@ export const Route = createRootRoute({ function AppConfigurator() { const { getToken } = useAuth(); useEffect(() => { - setConfig({ API_BASE_URL: process.env.API_BASE_URL ?? "", getToken }); + setConfig({ + API_BASE_URL: process.env.API_BASE_URL ?? "", + getToken, + ROOMS_HOTEL_ID: + import.meta.env.VITE_ROOMS_HOTEL_ID || + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", + }); }, [getToken]); return null; From 0db995bc86a62cc413bed3e39049e0e2d6dce46e Mon Sep 17 00:00:00 2001 From: Ari Spokony Date: Sat, 4 Apr 2026 19:04:25 -0400 Subject: [PATCH 2/7] inial backend changes from old PR --- backend/docs/swagger.yaml | 243 +++++++++++++++ backend/internal/errs/http.go | 5 + backend/internal/errs/repository.go | 5 + backend/internal/handler/auth_context.go | 38 +++ backend/internal/handler/requests_test.go | 32 ++ backend/internal/handler/tasks.go | 281 ++++++++++++++++++ backend/internal/handler/tasks_test.go | 280 +++++++++++++++++ backend/internal/models/requests.go | 1 + backend/internal/models/tasks.go | 57 ++++ backend/internal/repository/requests.go | 241 +++++++++++++-- backend/internal/service/server.go | 15 +- .../service/storage/postgres/repo_types.go | 4 + backend/internal/utils/cursor_pagination.go | 3 + backend/internal/utils/task_cursor.go | 89 ++++++ ...00000_add_location_display_to_requests.sql | 1 + 15 files changed, 1261 insertions(+), 34 deletions(-) create mode 100644 backend/internal/handler/auth_context.go create mode 100644 backend/internal/handler/tasks.go create mode 100644 backend/internal/handler/tasks_test.go create mode 100644 backend/internal/models/tasks.go create mode 100644 backend/internal/utils/task_cursor.go create mode 100644 backend/supabase/migrations/20260404100000_add_location_display_to_requests.sql diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 182e8c6ec..bb52aa47c 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -314,6 +314,9 @@ definitions: hotel_id: example: 521e8400-e458-41d4-a716-446655440000 type: string + location_display: + example: North wing, 12B + type: string name: example: room cleaning type: string @@ -411,6 +414,9 @@ definitions: id: example: 530e8400-e458-41d4-a716-446655440000 type: string + location_display: + example: North wing, 12B + type: string name: example: room cleaning type: string @@ -575,6 +581,25 @@ definitions: x-enum-varnames: - BookingStatusActive - BookingStatusInactive + github_com_generate_selfserve_internal_models.CreateTaskBody: + properties: + assign_to_me: + type: boolean + department: + type: string + description: + type: string + name: + type: string + priority: + enum: + - low + - medium + - high + type: string + required: + - name + type: object github_com_generate_selfserve_internal_models.NotificationType: enum: - task_assigned @@ -583,6 +608,39 @@ definitions: x-enum-varnames: - TypeTaskAssigned - TypeHighPriorityTask + github_com_generate_selfserve_internal_models.PatchTaskBody: + properties: + status: + enum: + - pending + - assigned + - in progress + - completed + type: string + required: + - status + type: object + github_com_generate_selfserve_internal_models.Task: + properties: + department: + type: string + description: + type: string + due_time: + type: string + id: + type: string + is_assigned: + type: boolean + location: + type: string + priority: + type: string + status: + type: string + title: + type: string + type: object github_com_generate_selfserve_internal_utils.CursorPage-RoomWithOptionalGuestBooking: properties: has_more: @@ -595,6 +653,18 @@ definitions: description: nil when no more pages type: string type: object + github_com_generate_selfserve_internal_utils.CursorPage-github_com_generate_selfserve_internal_models_Task: + properties: + has_more: + type: boolean + items: + items: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.Task' + type: array + next_cursor: + description: nil when no more pages + type: string + type: object internal_handler.UpdateProfilePictureRequest: description: Request body containing the S3 key after uploading properties: @@ -1361,6 +1431,179 @@ paths: summary: Get presigned URL for profile picture upload tags: - s3 + /tasks: + get: + description: Cursor-paginated tasks for my work or the unassigned pool + parameters: + - description: my or unassigned + in: query + name: tab + required: true + type: string + - description: Page size (default 20) + in: query + name: limit + type: integer + - description: Opaque cursor + in: query + name: cursor + type: string + - description: Filter by status + in: query + name: status + type: string + - description: Filter by department (case-insensitive) + in: query + name: department + type: string + - description: Filter by priority + in: query + name: priority + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_utils.CursorPage-github_com_generate_selfserve_internal_models_Task' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: List staff tasks + tags: + - tasks + post: + consumes: + - application/json + description: Creates a lightweight adhoc request for the authenticated user's + hotel + parameters: + - description: Task + in: body + name: body + required: true + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.CreateTaskBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Create adhoc staff task + tags: + - tasks + /tasks/{id}: + patch: + consumes: + - application/json + parameters: + - description: Request id + in: path + name: id + required: true + type: string + - description: Patch + in: body + name: body + required: true + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.PatchTaskBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Update task status + tags: + - tasks + /tasks/{id}/claim: + post: + parameters: + - description: Request id + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Claim unassigned task + tags: + - tasks + /tasks/{id}/drop: + post: + parameters: + - description: Request id + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Drop claimed task back to pool + tags: + - tasks /users: post: consumes: diff --git a/backend/internal/errs/http.go b/backend/internal/errs/http.go index 5467d31e2..9a2febfcc 100644 --- a/backend/internal/errs/http.go +++ b/backend/internal/errs/http.go @@ -43,6 +43,11 @@ func Conflict(title string, withKey string, withValue any) HTTPError { return NewHTTPError(http.StatusConflict, fmt.Errorf("conflict: %s with %s='%s' already exists", title, withKey, withValue)) } +// TaskStateHTTPConflict is returned when claim/drop or similar task preconditions fail. +func TaskStateHTTPConflict() HTTPError { + return NewHTTPError(http.StatusConflict, errors.New("task state conflict")) +} + func InvalidRequestData(errors map[string]string) HTTPError { return HTTPError{ Code: http.StatusUnprocessableEntity, diff --git a/backend/internal/errs/repository.go b/backend/internal/errs/repository.go index a929d602a..9ecdbdcb6 100644 --- a/backend/internal/errs/repository.go +++ b/backend/internal/errs/repository.go @@ -6,4 +6,9 @@ import "errors" var ( ErrNotFoundInDB = errors.New("not found in DB") ErrAlreadyExistsInDB = errors.New("already exists in DB") + + ErrTaskStateConflict = errors.New("task state conflict") + ErrRequestUnknownHotel = errors.New("unknown hotel for request") + ErrRequestUnknownAssignee = errors.New("unknown assignee for request") + ErrRequestInvalidUserID = errors.New("invalid user id for request") ) diff --git a/backend/internal/handler/auth_context.go b/backend/internal/handler/auth_context.go new file mode 100644 index 000000000..0ec950973 --- /dev/null +++ b/backend/internal/handler/auth_context.go @@ -0,0 +1,38 @@ +package handler + +import ( + "context" + "errors" + "strings" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" + "github.com/gofiber/fiber/v2" +) + +type authUserLookup interface { + FindUser(ctx context.Context, id string) (*models.User, error) +} + +// userIDAndHotelFromAuth resolves the Clerk subject from JWT locals and loads the user's hotel_id. +func userIDAndHotelFromAuth(c *fiber.Ctx, users authUserLookup) (clerkID, hotelID string, err error) { + raw := c.Locals("userId") + clerkID, _ = raw.(string) + if strings.TrimSpace(clerkID) == "" { + return "", "", errs.Unauthorized() + } + + u, ferr := users.FindUser(c.Context(), clerkID) + if ferr != nil { + if errors.Is(ferr, errs.ErrNotFoundInDB) { + return "", "", errs.BadRequest("user is not registered; complete sign-up first") + } + return "", "", errs.InternalServerError() + } + + if u.HotelID == nil || strings.TrimSpace(*u.HotelID) == "" { + return "", "", errs.BadRequest("user has no hotel assigned") + } + + return clerkID, *u.HotelID, nil +} diff --git a/backend/internal/handler/requests_test.go b/backend/internal/handler/requests_test.go index a9c6f1a87..e50fbd633 100644 --- a/backend/internal/handler/requests_test.go +++ b/backend/internal/handler/requests_test.go @@ -24,6 +24,10 @@ type mockRequestRepository struct { findRequestsFunc func(ctx context.Context) ([]models.Request, error) findRequestsByCursorFunc func(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) findRequestsByGuestIDFunc func(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) + findTasksFunc func(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) + updateTaskStatusFunc func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error + claimTaskFunc func(ctx context.Context, hotelID, requestID, clerkUserID string) error + dropTaskFunc func(ctx context.Context, hotelID, requestID, clerkUserID string) error } func (m *mockRequestRepository) InsertRequest(ctx context.Context, req *models.Request) (*models.Request, error) { @@ -54,6 +58,34 @@ func (m *mockRequestRepository) FindRequestsByGuestID(ctx context.Context, guest return m.findRequestsByGuestIDFunc(ctx, guestID, hotelID, cursorID, cursorVersion, limit) } +func (m *mockRequestRepository) FindTasks(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) { + if m.findTasksFunc == nil { + return []models.Task{}, nil + } + return m.findTasksFunc(ctx, hotelID, clerkUserID, filter, cursorRank, cursorDeptKey, cursorCreatedAt, cursorID, hasCursor) +} + +func (m *mockRequestRepository) UpdateTaskStatus(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + if m.updateTaskStatusFunc == nil { + return nil + } + return m.updateTaskStatusFunc(ctx, hotelID, requestID, clerkUserID, newStatus) +} + +func (m *mockRequestRepository) ClaimTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + if m.claimTaskFunc == nil { + return nil + } + return m.claimTaskFunc(ctx, hotelID, requestID, clerkUserID) +} + +func (m *mockRequestRepository) DropTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + if m.dropTaskFunc == nil { + return nil + } + return m.dropTaskFunc(ctx, hotelID, requestID, clerkUserID) +} + func TestRequestHandler_GetRequest(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/tasks.go b/backend/internal/handler/tasks.go new file mode 100644 index 000000000..2ece79678 --- /dev/null +++ b/backend/internal/handler/tasks.go @@ -0,0 +1,281 @@ +package handler + +import ( + "errors" + "strings" + "time" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/httpx" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/generate/selfserve/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" +) + +type TasksHandler struct { + repo storage.RequestsRepository + users authUserLookup +} + +func NewTasksHandler(repo storage.RequestsRepository, users authUserLookup) *TasksHandler { + return &TasksHandler{repo: repo, users: users} +} + +// GetTasks godoc +// @Summary List staff tasks +// @Description Cursor-paginated tasks for my work or the unassigned pool +// @Tags tasks +// @Produce json +// @Param tab query string true "my or unassigned" +// @Param limit query int false "Page size (default 20)" +// @Param cursor query string false "Opaque cursor" +// @Param status query string false "Filter by status" +// @Param department query string false "Filter by department (case-insensitive)" +// @Param priority query string false "Filter by priority" +// @Success 200 {object} utils.CursorPage[models.Task] +// @Failure 400 {object} errs.HTTPError +// @Failure 401 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks [get] +func (h *TasksHandler) GetTasks(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + var q models.TaskFilter + if err := c.QueryParser(&q); err != nil { + return errs.BadRequest("invalid query parameters") + } + if err := httpx.Validate(&q); err != nil { + return err + } + if !q.Tab.IsValid() { + return errs.BadRequest("tab must be my or unassigned") + } + + hasCursor := strings.TrimSpace(q.Cursor) != "" + var ( + cPR int + cDK string + cCA time.Time + cID string + ) + if hasCursor { + cPR, cDK, cCA, cID, err = utils.DecodeTaskCursor(q.Cursor, q.Tab) + if err != nil { + return errs.BadRequest(err.Error()) + } + } + + items, err := h.repo.FindTasks(c.Context(), hotelID, clerkID, q, cPR, cDK, cCA, cID, hasCursor) + if err != nil { + return errs.InternalServerError() + } + + page := utils.BuildCursorPage(items, utils.ResolveLimit(q.Limit), func(t models.Task) string { return t.Cursor }) + return c.JSON(page) +} + +// CreateTask godoc +// @Summary Create adhoc staff task +// @Description Creates a lightweight adhoc request for the authenticated user's hotel +// @Tags tasks +// @Accept json +// @Produce json +// @Param body body models.CreateTaskBody true "Task" +// @Success 200 {object} models.Request +// @Failure 400 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks [post] +func (h *TasksHandler) CreateTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + var body models.CreateTaskBody + if err := httpx.BindAndValidate(c, &body); err != nil { + return err + } + + priority := strings.TrimSpace(body.Priority) + if priority == "" { + priority = string(models.PriorityMedium) + } + + status := string(models.StatusPending) + var userID *string + if body.AssignToMe { + u := clerkID + userID = &u + status = string(models.StatusAssigned) + } + + desc := strings.TrimSpace(body.Description) + var descPtr *string + if desc != "" { + descPtr = &desc + } + + dept := strings.TrimSpace(body.Department) + var deptPtr *string + if dept != "" { + deptPtr = &dept + } + + emptyNotes := "" + req := &models.Request{ + ID: uuid.New().String(), + MakeRequest: models.MakeRequest{ + HotelID: hotelID, + Name: strings.TrimSpace(body.Name), + Description: descPtr, + RequestType: "adhoc", + Status: status, + Priority: priority, + Department: deptPtr, + UserID: userID, + Notes: &emptyNotes, + }, + } + + res, err := h.repo.InsertRequest(c.Context(), req) + if err != nil { + var pe *pgconn.PgError + if errors.As(err, &pe) && pe.Code == "23503" { + return errs.BadRequest("invalid hotel or user reference") + } + return errs.InternalServerError() + } + + return c.JSON(res) +} + +// PatchTask godoc +// @Summary Update task status +// @Tags tasks +// @Accept json +// @Produce json +// @Param id path string true "Request id" +// @Param body body models.PatchTaskBody true "Patch" +// @Success 200 {object} models.Request +// @Failure 400 {object} errs.HTTPError +// @Failure 404 {object} errs.HTTPError +// @Failure 409 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks/{id} [patch] +func (h *TasksHandler) PatchTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + id := c.Params("id") + if _, err := uuid.Parse(id); err != nil { + return errs.BadRequest("task id is not a valid UUID") + } + + var body models.PatchTaskBody + if err := httpx.BindAndValidate(c, &body); err != nil { + return err + } + + err = h.repo.UpdateTaskStatus(c.Context(), hotelID, id, clerkID, body.Status) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("task", "id", id) + } + if errors.Is(err, errs.ErrTaskStateConflict) { + return errs.TaskStateHTTPConflict() + } + return errs.InternalServerError() + } + + updated, err := h.repo.FindRequest(c.Context(), id) + if err != nil { + return errs.InternalServerError() + } + return c.JSON(updated) +} + +// ClaimTask godoc +// @Summary Claim unassigned task +// @Tags tasks +// @Produce json +// @Param id path string true "Request id" +// @Success 200 {object} models.Request +// @Failure 404 {object} errs.HTTPError +// @Failure 409 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks/{id}/claim [post] +func (h *TasksHandler) ClaimTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + id := c.Params("id") + if _, err := uuid.Parse(id); err != nil { + return errs.BadRequest("task id is not a valid UUID") + } + + err = h.repo.ClaimTask(c.Context(), hotelID, id, clerkID) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("task", "id", id) + } + if errors.Is(err, errs.ErrTaskStateConflict) { + return errs.TaskStateHTTPConflict() + } + return errs.InternalServerError() + } + + updated, err := h.repo.FindRequest(c.Context(), id) + if err != nil { + return errs.InternalServerError() + } + return c.JSON(updated) +} + +// DropTask godoc +// @Summary Drop claimed task back to pool +// @Tags tasks +// @Produce json +// @Param id path string true "Request id" +// @Success 200 {object} models.Request +// @Failure 404 {object} errs.HTTPError +// @Failure 409 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks/{id}/drop [post] +func (h *TasksHandler) DropTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + id := c.Params("id") + if _, err := uuid.Parse(id); err != nil { + return errs.BadRequest("task id is not a valid UUID") + } + + err = h.repo.DropTask(c.Context(), hotelID, id, clerkID) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("task", "id", id) + } + if errors.Is(err, errs.ErrTaskStateConflict) { + return errs.TaskStateHTTPConflict() + } + return errs.InternalServerError() + } + + updated, err := h.repo.FindRequest(c.Context(), id) + if err != nil { + return errs.InternalServerError() + } + return c.JSON(updated) +} diff --git a/backend/internal/handler/tasks_test.go b/backend/internal/handler/tasks_test.go new file mode 100644 index 000000000..0a092685b --- /dev/null +++ b/backend/internal/handler/tasks_test.go @@ -0,0 +1,280 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" + "github.com/generate/selfserve/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockAuthUsers struct { + findUser func(ctx context.Context, id string) (*models.User, error) +} + +func (m *mockAuthUsers) FindUser(ctx context.Context, id string) (*models.User, error) { + if m.findUser == nil { + h := "521e8400-e458-41d4-a716-446655440000" + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: &h}}, nil + } + return m.findUser(ctx, id) +} + +func testTasksApp(t *testing.T, repo *mockRequestRepository, users *mockAuthUsers, localsUserID string) *fiber.App { + t.Helper() + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + if localsUserID != "" { + app.Use(func(c *fiber.Ctx) error { + c.Locals("userId", localsUserID) + return c.Next() + }) + } + h := NewTasksHandler(repo, users) + app.Get("/tasks", h.GetTasks) + app.Post("/tasks", h.CreateTask) + app.Patch("/tasks/:id", h.PatchTask) + app.Post("/tasks/:id/claim", h.ClaimTask) + app.Post("/tasks/:id/drop", h.DropTask) + return app +} + +func TestTasksHandler_GetTasks(t *testing.T) { + t.Parallel() + + t.Run("returns 401 when userId missing", func(t *testing.T) { + t.Parallel() + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{}, "") + req := httptest.NewRequest("GET", "/tasks?tab=my", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) + }) + + t.Run("returns 400 when user has no hotel", func(t *testing.T) { + t.Parallel() + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{ + findUser: func(ctx context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: nil}}, nil + }, + }, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks?tab=my", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 400 when tab missing", func(t *testing.T) { + t.Parallel() + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 400 when cursor tab mismatches", func(t *testing.T) { + t.Parallel() + cur, err := utils.EncodeTaskCursor(models.TaskTabUnassigned, 2, "hk", time.Now().UTC(), "00000000-0000-0000-0000-000000000099") + require.NoError(t, err) + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks?tab=my&cursor="+cur, nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 200 with items", func(t *testing.T) { + t.Parallel() + ts := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC) + dept := "Housekeeping" + task := models.Task{ + ID: "00000000-0000-0000-0000-0000000000aa", Title: "Clean", Priority: "high", + Department: &dept, Location: "Room 101", Status: "assigned", IsAssigned: true, + } + cur, err := utils.EncodeTaskCursor(models.TaskTabMy, utils.PriorityRank("high"), utils.DepartmentKey(&dept), ts, task.ID) + require.NoError(t, err) + task.Cursor = cur + + repo := &mockRequestRepository{ + findTasksFunc: func(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) { + assert.Equal(t, "521e8400-e458-41d4-a716-446655440000", hotelID) + assert.Equal(t, "user_clerk_1", clerkUserID) + assert.Equal(t, models.TaskTabMy, filter.Tab) + return []models.Task{task}, nil + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks?tab=my&limit=20", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), `"items"`) + assert.Contains(t, string(body), "Clean") + assert.Contains(t, string(body), `"has_more":false`) + }) +} + +func TestTasksHandler_CreateTask(t *testing.T) { + t.Parallel() + + t.Run("returns 200", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + makeRequestFunc: func(ctx context.Context, req *models.Request) (*models.Request, error) { + assert.Equal(t, "adhoc", req.RequestType) + assert.Equal(t, string(models.StatusPending), req.Status) + req.CreatedAt = time.Now() + req.RequestVersion = time.Now() + return req, nil + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("POST", "/tasks", strings.NewReader(`{"name":"Quick task"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) +} + +func TestTasksHandler_PatchTask(t *testing.T) { + t.Parallel() + rid := "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 409 on state conflict", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + return errs.ErrTaskStateConflict + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("PATCH", "/tasks/"+rid, strings.NewReader(`{"status":"completed"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) + + t.Run("returns 404 when not found", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + return errs.ErrNotFoundInDB + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("PATCH", "/tasks/"+rid, strings.NewReader(`{"status":"completed"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + }) + + t.Run("returns 200", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + return nil + }, + findRequestFunc: func(ctx context.Context, id string) (*models.Request, error) { + return &models.Request{ + ID: id, CreatedAt: time.Now(), RequestVersion: time.Now(), + MakeRequest: models.MakeRequest{ + HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "x", RequestType: "adhoc", + Status: "completed", Priority: "low", Notes: ptrStr(""), + }, + }, nil + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("PATCH", "/tasks/"+rid, strings.NewReader(`{"status":"completed"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) +} + +func ptrStr(s string) *string { return &s } + +func TestTasksHandler_ClaimTask(t *testing.T) { + t.Parallel() + rid := "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 409 on conflict", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + claimTaskFunc: func(ctx context.Context, hotelID, requestID, clerkUserID string) error { + return errs.ErrTaskStateConflict + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("POST", "/tasks/"+rid+"/claim", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) +} + +func TestTasksHandler_DropTask(t *testing.T) { + t.Parallel() + rid := "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 409 on conflict", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + dropTaskFunc: func(ctx context.Context, hotelID, requestID, clerkUserID string) error { + return errs.ErrTaskStateConflict + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("POST", "/tasks/"+rid+"/drop", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) +} + +func TestTaskCursorRoundTrip(t *testing.T) { + t.Parallel() + ts := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + cur, err := utils.EncodeTaskCursor(models.TaskTabMy, 3, "dept", ts, "11111111-1111-1111-1111-111111111111") + require.NoError(t, err) + pr, dk, ca, id, err := utils.DecodeTaskCursor(cur, models.TaskTabMy) + require.NoError(t, err) + assert.Equal(t, 3, pr) + assert.Equal(t, "dept", dk) + assert.True(t, ca.Equal(ts)) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", id) +} + +func TestTaskCursorWrongTab(t *testing.T) { + t.Parallel() + cur, err := utils.EncodeTaskCursor(models.TaskTabMy, 1, "", time.Now(), "11111111-1111-1111-1111-111111111111") + require.NoError(t, err) + _, _, _, _, err = utils.DecodeTaskCursor(cur, models.TaskTabUnassigned) + assert.Error(t, err) +} + +// Ensure CursorPage JSON shape for mobile. +func TestCursorPageTasksJSON(t *testing.T) { + t.Parallel() + p := utils.CursorPage[models.Task]{ + Items: []models.Task{{ID: "a", Title: "t", Location: "x", Status: "pending", IsAssigned: false}}, + HasMore: false, + } + b, err := json.Marshal(p) + require.NoError(t, err) + assert.Contains(t, string(b), `"next_cursor":null`) +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index 21152c6aa..5f21a3e42 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -46,6 +46,7 @@ type MakeRequest struct { Name string `json:"name" validate:"notblank" example:"room cleaning"` Description *string `json:"description" example:"clean 504"` RoomID *string `json:"room_id" example:"521e8422-e458-41d4-a716-446655440000"` + LocationDisplay *string `json:"location_display,omitempty" example:"North wing, 12B"` RequestCategory *string `json:"request_category" example:"Cleaning"` RequestType string `json:"request_type" validate:"notblank" example:"recurring"` Department *string `json:"department" example:"maintenance"` diff --git a/backend/internal/models/tasks.go b/backend/internal/models/tasks.go new file mode 100644 index 000000000..9834d3164 --- /dev/null +++ b/backend/internal/models/tasks.go @@ -0,0 +1,57 @@ +package models + +import "time" + +// TaskTab selects which task list is being fetched. +type TaskTab string + +const ( + TaskTabMy TaskTab = "my" + TaskTabUnassigned TaskTab = "unassigned" +) + +func (t TaskTab) IsValid() bool { + switch t { + case TaskTabMy, TaskTabUnassigned: + return true + } + return false +} + +// TaskFilter captures GET /tasks query parameters. +type TaskFilter struct { + Tab TaskTab `query:"tab" validate:"required,oneof=my unassigned"` + Cursor string `query:"cursor"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100"` + Status string `query:"status" validate:"omitempty,oneof='pending' 'assigned' 'in progress' 'completed'"` + Department string `query:"department"` + Priority string `query:"priority" validate:"omitempty,oneof=low medium high urgent"` +} + +// Task is the staff-facing JSON shape for list/detail. +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Priority string `json:"priority"` + Department *string `json:"department,omitempty"` + Location string `json:"location"` + Description *string `json:"description,omitempty"` + DueTime *time.Time `json:"due_time,omitempty"` + Status string `json:"status"` + IsAssigned bool `json:"is_assigned"` + Cursor string `json:"-"` +} + +// PatchTaskBody is the body for PATCH /tasks/:id. +type PatchTaskBody struct { + Status string `json:"status" validate:"required,oneof='pending' 'assigned' 'in progress' 'completed'"` +} + +// CreateTaskBody is the body for POST /tasks (adhoc staff task). +type CreateTaskBody struct { + Name string `json:"name" validate:"required,notblank"` + AssignToMe bool `json:"assign_to_me"` + Description string `json:"description" validate:"omitempty"` + Priority string `json:"priority" validate:"omitempty,oneof=low medium high"` + Department string `json:"department" validate:"omitempty"` +} diff --git a/backend/internal/repository/requests.go b/backend/internal/repository/requests.go index 775a76fa4..038f811a5 100644 --- a/backend/internal/repository/requests.go +++ b/backend/internal/repository/requests.go @@ -3,14 +3,37 @@ package repository import ( "context" "errors" + "fmt" + "strings" "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" + "github.com/generate/selfserve/internal/utils" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) +// requestsRowColumns matches public.requests column order for full-row scans (includes location_display). +const requestsRowColumns = `id, hotel_id, guest_id, user_id, reservation_id, name, description, room_id, request_category, request_type, department, status, priority, estimated_completion_time, scheduled_time, completed_at, notes, created_at, request_version, location_display` + +func scanRequestRow(scanner interface { + Scan(dest ...any) error +}) (*models.Request, error) { + var request models.Request + err := scanner.Scan( + &request.ID, &request.HotelID, &request.GuestID, &request.UserID, + &request.ReservationID, &request.Name, &request.Description, + &request.RoomID, &request.RequestCategory, &request.RequestType, &request.Department, &request.Status, + &request.Priority, &request.EstimatedCompletionTime, &request.ScheduledTime, &request.CompletedAt, &request.Notes, + &request.CreatedAt, &request.RequestVersion, &request.LocationDisplay, + ) + if err != nil { + return nil, err + } + return &request, nil +} + type RequestsRepository struct { db *pgxpool.Pool } @@ -29,9 +52,9 @@ func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Requ id, hotel_id, guest_id, user_id, reservation_id, name, description, room_id, request_category, request_type, department, status, priority, estimated_completion_time, scheduled_time, notes, - request_version, created_at + location_display, request_version, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), COALESCE((SELECT MIN(created_at) FROM requests WHERE id = $1), NOW()) ) @@ -39,7 +62,7 @@ func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Requ `, req.ID, req.HotelID, req.GuestID, req.UserID, req.ReservationID, req.Name, req.Description, req.RoomID, req.RequestCategory, req.RequestType, req.Department, req.Status, req.Priority, req.EstimatedCompletionTime, - req.ScheduledTime, req.Notes).Scan(&req.ID, &req.CreatedAt, &req.RequestVersion) + req.ScheduledTime, req.Notes, req.LocationDisplay).Scan(&req.ID, &req.CreatedAt, &req.RequestVersion) if err != nil { return nil, err @@ -51,20 +74,14 @@ func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Requ func (r *RequestsRepository) FindRequest(ctx context.Context, id string) (*models.Request, error) { row := r.db.QueryRow(ctx, ` - SELECT * + SELECT `+requestsRowColumns+` FROM requests WHERE id = $1 ORDER BY request_version DESC LIMIT 1 `, id) - var request models.Request - - err := row.Scan(&request.ID, &request.HotelID, &request.GuestID, - &request.ReservationID, &request.Name, &request.Description, - &request.RoomID, &request.RequestCategory, &request.RequestType, &request.Department, &request.Status, - &request.Priority, &request.EstimatedCompletionTime, &request.ScheduledTime, &request.CompletedAt, &request.Notes, - &request.CreatedAt, &request.UserID, &request.RequestVersion) + request, err := scanRequestRow(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -73,12 +90,12 @@ func (r *RequestsRepository) FindRequest(ctx context.Context, id string) (*model return nil, err } - return &request, nil + return request, nil } func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) { rows, err := r.db.Query(ctx, ` - SELECT * + SELECT `+requestsRowColumns+` FROM requests WHERE id > $1 AND status = $2 AND hotel_id = $3 ORDER BY id @@ -93,16 +110,11 @@ func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, var requests []*models.Request for rows.Next() { - var request models.Request - err := rows.Scan(&request.ID, &request.HotelID, &request.GuestID, - &request.ReservationID, &request.Name, &request.Description, - &request.RoomID, &request.RequestCategory, &request.RequestType, &request.Department, &request.Status, - &request.Priority, &request.EstimatedCompletionTime, &request.ScheduledTime, &request.CompletedAt, &request.Notes, - &request.CreatedAt, &request.UserID, &request.RequestVersion) - if err != nil { - return nil, "", err + request, scanErr := scanRequestRow(rows) + if scanErr != nil { + return nil, "", scanErr } - requests = append(requests, &request) + requests = append(requests, request) } if err := rows.Err(); err != nil { @@ -117,7 +129,7 @@ func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, } func (r *RequestsRepository) FindRequests(ctx context.Context) ([]models.Request, error) { - rows, err := r.db.Query(ctx, `SELECT * FROM requests ORDER BY created_at DESC`) + rows, err := r.db.Query(ctx, `SELECT `+requestsRowColumns+` FROM requests ORDER BY created_at DESC`) if err != nil { return nil, err } @@ -125,16 +137,11 @@ func (r *RequestsRepository) FindRequests(ctx context.Context) ([]models.Request var requests []models.Request for rows.Next() { - var request models.Request - err := rows.Scan(&request.ID, &request.HotelID, &request.GuestID, - &request.ReservationID, &request.Name, &request.Description, - &request.RoomID, &request.RequestCategory, &request.RequestType, &request.Department, &request.Status, - &request.Priority, &request.EstimatedCompletionTime, &request.ScheduledTime, &request.CompletedAt, &request.Notes, - &request.CreatedAt, &request.UserID, &request.RequestVersion) - if err != nil { - return nil, err + request, scanErr := scanRequestRow(rows) + if scanErr != nil { + return nil, scanErr } - requests = append(requests, request) + requests = append(requests, *request) } if err := rows.Err(); err != nil { @@ -183,3 +190,173 @@ func (r *RequestsRepository) FindRequestsByGuestID(ctx context.Context, guestID, return requests, rows.Err() } + +// FindTasks returns up to limit+1 tasks for cursor pagination (newest sort keys first within each tab). +func (r *RequestsRepository) FindTasks(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) { + limit := utils.ResolveLimit(filter.Limit) + 1 + tab := string(filter.Tab) + statusOv := strings.TrimSpace(filter.Status) + deptF := strings.TrimSpace(filter.Department) + priF := strings.TrimSpace(filter.Priority) + + orderBy := `r.pr DESC, r.created_at DESC, r.id DESC` + if filter.Tab == models.TaskTabUnassigned { + orderBy = `r.dk ASC, r.pr DESC, r.created_at DESC, r.id DESC` + } + + args := []any{hotelID, tab, clerkUserID, statusOv, deptF, priF} + next := 7 + var cursorSQL string + if hasCursor { + if filter.Tab == models.TaskTabMy { + cursorSQL = fmt.Sprintf( + ` AND (r.pr < $%d OR (r.pr = $%d AND r.created_at < $%d) OR (r.pr = $%d AND r.created_at = $%d AND r.id::uuid < $%d::uuid))`, + next, next, next+1, next, next+1, next+2, + ) + args = append(args, cursorRank, cursorCreatedAt, cursorID) + next += 3 + } else { + cursorSQL = fmt.Sprintf( + ` AND (r.dk > $%d OR (r.dk = $%d AND (r.pr < $%d OR (r.pr = $%d AND r.created_at < $%d) OR (r.pr = $%d AND r.created_at = $%d AND r.id::uuid < $%d::uuid))))`, + next, next, next+1, next+1, next+2, next+1, next+2, next+3, + ) + args = append(args, cursorDeptKey, cursorRank, cursorCreatedAt, cursorID) + next += 4 + } + } + args = append(args, limit) + limitParam := next + + query := fmt.Sprintf(` +WITH latest AS ( + SELECT DISTINCT ON (r.id) + r.id, r.user_id, r.name, r.priority, r.department, r.status, r.description, r.scheduled_time, r.created_at, + r.room_id, r.location_display + FROM requests r + WHERE r.hotel_id = $1 + ORDER BY r.id ASC, r.request_version DESC +), +ranked AS ( + SELECT + l.*, + CASE LOWER(TRIM(COALESCE(l.priority, ''))) + WHEN 'urgent' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'middle' THEN 2 WHEN 'low' THEN 1 ELSE 0 + END AS pr, + LOWER(TRIM(COALESCE(l.department, ''))) AS dk + FROM latest l + WHERE + ($2 = 'my' AND l.user_id = $3 AND (($4 <> '' AND l.status = $4) OR ($4 = '' AND l.status IN ('assigned', 'in progress')))) + OR + ($2 = 'unassigned' AND l.user_id IS NULL AND (($4 <> '' AND l.status = $4) OR ($4 = '' AND l.status = 'pending'))) +) +SELECT + r.id, + r.name, + r.priority, + r.department, + r.status, + r.description, + r.scheduled_time, + r.created_at, + (r.user_id IS NOT NULL AND r.user_id <> '') AS is_assigned, + CASE + WHEN r.location_display IS NOT NULL AND TRIM(r.location_display) <> '' THEN TRIM(r.location_display) + WHEN r.room_id IS NOT NULL AND TRIM(r.room_id::text) <> '' THEN 'Room ' || r.room_id::text + ELSE 'Room unavailable' + END AS loc +FROM ranked r +WHERE ($5 = '' OR r.dk = LOWER(TRIM($5))) + AND ($6 = '' OR r.priority = $6) +%s +ORDER BY %s +LIMIT $%d`, cursorSQL, orderBy, limitParam) + + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []models.Task + for rows.Next() { + var t models.Task + var dept *string + var createdAt time.Time + if err := rows.Scan( + &t.ID, &t.Title, &t.Priority, &dept, &t.Status, &t.Description, &t.DueTime, &createdAt, &t.IsAssigned, &t.Location, + ); err != nil { + return nil, err + } + t.Department = dept + cur, err := utils.EncodeTaskCursor(filter.Tab, utils.PriorityRank(t.Priority), utils.DepartmentKey(dept), createdAt, t.ID) + if err != nil { + return nil, err + } + t.Cursor = cur + out = append(out, t) + } + return out, rows.Err() +} + +// UpdateTaskStatus inserts a new request version with an updated status. +func (r *RequestsRepository) UpdateTaskStatus(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + base, err := r.FindRequest(ctx, requestID) + if err != nil { + return err + } + if base.HotelID != hotelID { + return errs.ErrNotFoundInDB + } + if base.UserID != nil && strings.TrimSpace(*base.UserID) != "" && *base.UserID != clerkUserID { + return errs.ErrTaskStateConflict + } + mr := base.MakeRequest + mr.Status = newStatus + _, err = r.InsertRequest(ctx, &models.Request{ID: base.ID, MakeRequest: mr}) + return err +} + +// ClaimTask assigns a pending unassigned task to the given staff user (Clerk id). +func (r *RequestsRepository) ClaimTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + base, err := r.FindRequest(ctx, requestID) + if err != nil { + return err + } + if base.HotelID != hotelID { + return errs.ErrNotFoundInDB + } + if base.UserID != nil && strings.TrimSpace(*base.UserID) != "" { + return errs.ErrTaskStateConflict + } + if base.Status != string(models.StatusPending) { + return errs.ErrTaskStateConflict + } + mr := base.MakeRequest + u := clerkUserID + mr.UserID = &u + mr.Status = string(models.StatusAssigned) + _, err = r.InsertRequest(ctx, &models.Request{ID: base.ID, MakeRequest: mr}) + return err +} + +// DropTask returns a task to the unassigned pool. +func (r *RequestsRepository) DropTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + base, err := r.FindRequest(ctx, requestID) + if err != nil { + return err + } + if base.HotelID != hotelID { + return errs.ErrNotFoundInDB + } + if base.UserID == nil || *base.UserID != clerkUserID { + return errs.ErrTaskStateConflict + } + if base.Status != string(models.StatusAssigned) && base.Status != string(models.StatusInProgress) { + return errs.ErrTaskStateConflict + } + mr := base.MakeRequest + mr.UserID = nil + mr.Status = string(models.StatusPending) + _, err = r.InsertRequest(ctx, &models.Request{ID: base.ID, MakeRequest: mr}) + return err +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 086a6633e..74d1ed3de 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -139,7 +139,9 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo devsHandler := handler.NewDevsHandler(repository.NewDevsRepository(repo.DB)) usersHandler := handler.NewUsersHandler(repository.NewUsersRepository(repo.DB), s3Store) guestsHandler := handler.NewGuestsHandler(repository.NewGuestsRepository(repo.DB), openSearchRepos.Guests) - reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) + reqsRepo := repository.NewRequestsRepo(repo.DB) + reqsHandler := handler.NewRequestsHandler(reqsRepo, genkitInstance, notifService) + tasksHandler := handler.NewTasksHandler(reqsRepo, usersRepo) hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB)) s3Handler := handler.NewS3Handler(s3Store) roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB)) @@ -202,6 +204,15 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo r.Get("/guest/:id", reqsHandler.GetRequestsByGuest) }) + // Staff tasks (requests as tasks) + api.Route("/tasks", func(r fiber.Router) { + r.Get("/", tasksHandler.GetTasks) + r.Post("/", tasksHandler.CreateTask) + r.Patch("/:id", tasksHandler.PatchTask) + r.Post("/:id/claim", tasksHandler.ClaimTask) + r.Post("/:id/drop", tasksHandler.DropTask) + }) + // Hotel routes api.Route("/hotels", func(r fiber.Router) { r.Get("/:id", hotelsHandler.GetHotelByID) @@ -262,7 +273,7 @@ func setupApp() *fiber.App { allowedOrigins := os.Getenv("APP_CORS_ORIGINS") app.Use(cors.New(cors.Config{ AllowOrigins: allowedOrigins, - AllowMethods: "GET,POST,PUT,DELETE", + AllowMethods: "GET,POST,PUT,PATCH,DELETE", AllowHeaders: "Origin, Content-Type, Authorization, X-Hotel-ID", AllowCredentials: true, })) diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 3f7c84eb5..065cbc5b3 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -48,6 +48,10 @@ type RequestsRepository interface { FindRequests(ctx context.Context) ([]models.Request, error) FindRequestsByStatusPaginated(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) FindRequestsByGuestID(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) + FindTasks(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) + UpdateTaskStatus(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error + ClaimTask(ctx context.Context, hotelID, requestID, clerkUserID string) error + DropTask(ctx context.Context, hotelID, requestID, clerkUserID string) error } type HotelsRepository interface { diff --git a/backend/internal/utils/cursor_pagination.go b/backend/internal/utils/cursor_pagination.go index 41e71643c..e07b9614a 100644 --- a/backend/internal/utils/cursor_pagination.go +++ b/backend/internal/utils/cursor_pagination.go @@ -21,6 +21,9 @@ type CursorPage[T any] struct { } func BuildCursorPage[T any](items []T, limit int, cursorFn func(T) string) CursorPage[T] { + if items == nil { + items = []T{} + } limit = ResolveLimit(limit) hasMore := len(items) > limit if hasMore { diff --git a/backend/internal/utils/task_cursor.go b/backend/internal/utils/task_cursor.go new file mode 100644 index 000000000..8179ad902 --- /dev/null +++ b/backend/internal/utils/task_cursor.go @@ -0,0 +1,89 @@ +package utils + +import ( + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/generate/selfserve/internal/models" +) + +const taskCursorVersion = 2 + +type taskCursorPayload struct { + V int `json:"v"` + Tab string `json:"tab"` + PR int `json:"pr"` + DK string `json:"dk"` + CA time.Time `json:"ca"` + ID string `json:"id"` +} + +// PriorityRank maps stored priority strings to a numeric rank (higher = more urgent). +func PriorityRank(priority string) int { + switch strings.ToLower(strings.TrimSpace(priority)) { + case "urgent": + return 4 + case "high": + return 3 + case "medium", "middle": + return 2 + case "low": + return 1 + default: + return 0 + } +} + +// DepartmentKey normalizes department for sorting and cursors. +func DepartmentKey(dept *string) string { + if dept == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*dept)) +} + +// EncodeTaskCursor builds an opaque cursor for the given sort key. +func EncodeTaskCursor(tab models.TaskTab, priorityRank int, deptKey string, createdAt time.Time, id string) (string, error) { + p := taskCursorPayload{ + V: taskCursorVersion, + Tab: string(tab), + PR: priorityRank, + DK: deptKey, + CA: createdAt.UTC(), + ID: id, + } + raw, err := json.Marshal(p) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// DecodeTaskCursor parses and validates a cursor; returns payload fields. +func DecodeTaskCursor(encoded string, expectedTab models.TaskTab) (priorityRank int, deptKey string, createdAt time.Time, id string, err error) { + encoded = strings.TrimSpace(encoded) + if encoded == "" { + return 0, "", time.Time{}, "", errors.New("empty cursor") + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return 0, "", time.Time{}, "", errors.New("invalid cursor encoding") + } + var p taskCursorPayload + if err := json.Unmarshal(raw, &p); err != nil { + return 0, "", time.Time{}, "", errors.New("invalid cursor payload") + } + if p.V != taskCursorVersion { + return 0, "", time.Time{}, "", errors.New("unsupported cursor version") + } + if models.TaskTab(p.Tab) != expectedTab { + return 0, "", time.Time{}, "", errors.New("cursor does not match tab") + } + if p.ID == "" { + return 0, "", time.Time{}, "", errors.New("invalid cursor id") + } + return p.PR, p.DK, p.CA.UTC(), p.ID, nil +} diff --git a/backend/supabase/migrations/20260404100000_add_location_display_to_requests.sql b/backend/supabase/migrations/20260404100000_add_location_display_to_requests.sql new file mode 100644 index 000000000..554f88af6 --- /dev/null +++ b/backend/supabase/migrations/20260404100000_add_location_display_to_requests.sql @@ -0,0 +1 @@ +ALTER TABLE public.requests ADD COLUMN IF NOT EXISTS location_display text; From 53601226de6b9f9b6f5e7f55ff55da88647a5f57 Mon Sep 17 00:00:00 2001 From: Ari Spokony Date: Tue, 7 Apr 2026 13:19:32 -0400 Subject: [PATCH 3/7] sizing changes and height changes --- clients/mobile/app/(tabs)/tasks.tsx | 4 +- .../components/tasks/active-filter-chips.tsx | 2 +- .../tasks/task-completion-modal.tsx | 6 +- .../components/tasks/task-detail-sheet.tsx | 161 +++++++++++++++--- .../components/tasks/task-filter-sheet.tsx | 14 +- clients/mobile/components/tasks/task-list.tsx | 2 +- .../mobile/components/tasks/tasks-header.tsx | 2 +- 7 files changed, 154 insertions(+), 37 deletions(-) diff --git a/clients/mobile/app/(tabs)/tasks.tsx b/clients/mobile/app/(tabs)/tasks.tsx index 85598e299..95a7397a4 100644 --- a/clients/mobile/app/(tabs)/tasks.tsx +++ b/clients/mobile/app/(tabs)/tasks.tsx @@ -165,12 +165,12 @@ export default function TasksScreen() { ) : null} {error ? ( - + {(error as Error).message || "Failed to load tasks"} ) : null} {isPending && !flatTasks.length ? ( - Loading… + Loading… ) : ( + {filters.map((filter) => ( Task completed - Nice work. Manager notes are not synced yet. + TODO: Add manager notes and confetti. - Done + Done diff --git a/clients/mobile/components/tasks/task-detail-sheet.tsx b/clients/mobile/components/tasks/task-detail-sheet.tsx index c02ceaa03..05d378e2d 100644 --- a/clients/mobile/components/tasks/task-detail-sheet.tsx +++ b/clients/mobile/components/tasks/task-detail-sheet.tsx @@ -1,14 +1,19 @@ import { BottomSheetBackdrop, BottomSheetModal, - BottomSheetView, + BottomSheetScrollView, } from "@gorhom/bottom-sheet"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import React, { useCallback } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Alert, Pressable, Text, View } from "react-native"; import { TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; import type { Task } from "@/types/tasks"; +/** Figma SelfServe task detail sheet — Primary */ +const PRIMARY_BLUE = "#004FC5"; +const LABEL_MUTED = "#A4A4A4"; + type TaskDetailSheetProps = { sheetRef: React.RefObject; task: Task | null; @@ -20,6 +25,46 @@ type TaskDetailSheetProps = { busy: boolean; }; +function MetadataGlyph() { + return ( + + + + + + + + + + + ); +} + +function MetadataRow({ label, value }: { label: string; value: string }) { + return ( + + + + + {label} + + + + + {value} + + + + ); +} + export function TaskDetailSheet({ sheetRef, task, @@ -36,6 +81,7 @@ export function TaskDetailSheet({ {...props} disappearsOnIndex={-1} appearsOnIndex={0} + opacity={0.3} /> ), [], @@ -43,48 +89,104 @@ export function TaskDetailSheet({ const assigned = variant === TASK_ASSIGNMENT_STATE.ASSIGNED; + const onHistoryPress = useCallback(() => { + Alert.alert( + "Task history", + "Activity history for this task is not available yet.", + ); + }, []); + + const deadlineDisplay = + task?.dueTime?.trim() && task.dueTime !== "—" + ? task.dueTime + : "Not set"; + return ( - + {!task ? null : ( <> - {task.title} - - {task.priority} · {task.department} · {task.location} - - {task.dueTime ? ( - - Due {task.dueTime} + + + {task.title} - ) : null} - {task.description ? ( - - {task.description} - - ) : null} + + + + + + + + + + + + + + + + Description + + + {task.description?.trim() + ? task.description + : "No description provided."} + + + - + {assigned && task.status === "assigned" ? ( onStart(task.id)} - className="bg-blue-600 rounded-xl py-3 items-center" + className="w-full h-10 px-6 items-center justify-center rounded-lg active:opacity-90" + style={{ backgroundColor: PRIMARY_BLUE }} > - Start + + Start + ) : null} {assigned && task.status === "in progress" ? ( onComplete(task.id)} - className="bg-blue-600 rounded-xl py-3 items-center" + className="w-full h-10 px-6 items-center justify-center rounded-lg active:opacity-90" + style={{ backgroundColor: PRIMARY_BLUE }} > - Mark done + + Mark done + ) : null} {assigned && @@ -92,24 +194,29 @@ export function TaskDetailSheet({ onDrop(task.id)} - className="border border-gray-300 rounded-xl py-3 items-center" + className="w-full h-10 px-6 items-center justify-center rounded-lg bg-white border border-neutral-300 active:bg-neutral-50" > - Drop task + + Drop Task + ) : null} {!assigned ? ( onClaim(task.id)} - className="bg-white border border-gray-300 rounded-xl py-3 items-center" + className="w-full h-11 px-6 items-center justify-center rounded-lg active:opacity-90" + style={{ backgroundColor: PRIMARY_BLUE }} > - Claim task + + Claim Task + ) : null} )} - + ); } diff --git a/clients/mobile/components/tasks/task-filter-sheet.tsx b/clients/mobile/components/tasks/task-filter-sheet.tsx index a2c711768..e85453480 100644 --- a/clients/mobile/components/tasks/task-filter-sheet.tsx +++ b/clients/mobile/components/tasks/task-filter-sheet.tsx @@ -4,7 +4,7 @@ import { BottomSheetScrollView, } from "@gorhom/bottom-sheet"; import React, { useCallback, useMemo, useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, useWindowDimensions, View } from "react-native"; import { TASK_FILTER_DEPARTMENTS, @@ -16,6 +16,9 @@ import { } from "@/constants/tasks"; import type { TasksFilterState } from "@/types/tasks"; +/** Sheet height = window height × this (smaller = sits lower on screen). */ +const FILTER_SHEET_HEIGHT_FRACTION = 0.82; + type TaskFilterSheetProps = { sheetRef: React.RefObject; activeTab: TabName; @@ -54,6 +57,7 @@ export function TaskFilterSheet({ applied, onApply, }: TaskFilterSheetProps) { + const { height: windowHeight } = useWindowDimensions(); const [draft, setDraft] = useState(applied); const statusOptions = useMemo( @@ -77,10 +81,16 @@ export function TaskFilterSheet({ const openSync = () => setDraft({ ...applied }); + const snapPoints = useMemo( + () => [Math.round(windowHeight * FILTER_SHEET_HEIGHT_FRACTION)], + [windowHeight, FILTER_SHEET_HEIGHT_FRACTION], + ); + return ( { diff --git a/clients/mobile/components/tasks/task-list.tsx b/clients/mobile/components/tasks/task-list.tsx index d9add5f7d..b940b0cc3 100644 --- a/clients/mobile/components/tasks/task-list.tsx +++ b/clients/mobile/components/tasks/task-list.tsx @@ -66,7 +66,7 @@ export function TaskList({ data={tasks} keyExtractor={(item) => item.id} renderItem={renderItem} - contentContainerClassName="px-[5vw] py-4 gap-4" + contentContainerClassName="px-5 py-4 gap-4" showsVerticalScrollIndicator={false} onEndReached={onEndReached} onEndReachedThreshold={0.35} diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index f15ac37ba..5ded8ce09 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -17,7 +17,7 @@ export function TasksHeader({ onOpenFilters, }: TasksHeaderProps) { return ( - + Tasks From b52f5cc3214c30425d8bf6da546a24cdd0001b54 Mon Sep 17 00:00:00 2001 From: Ari Spokony Date: Tue, 7 Apr 2026 15:33:58 -0400 Subject: [PATCH 4/7] use orval task muations, removed dead code --- .DS_Store | Bin 0 -> 6148 bytes backend/docs/swagger.yaml | 38 +++++++++ clients/mobile/data/mockTasks.ts | 99 ------------------------ clients/mobile/hooks/use-tasks-feed.ts | 73 +++++++++++++---- clients/shared/src/api/endpoints.ts | 3 - clients/shared/src/api/orval-mutator.ts | 5 +- clients/shared/src/index.ts | 7 ++ 7 files changed, 105 insertions(+), 120 deletions(-) create mode 100644 .DS_Store delete mode 100644 clients/mobile/data/mockTasks.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..70c20db5e0c0be3950c2c6f36644d72ac9e22382 GIT binary patch literal 6148 zcmeHKu}T9$5S@)j42qD_O0Ezq6@!h9a2^+J>{8h3g$-J3m4@u@e!* z(nf@UpWqi*I5WFR_Hte;5@ulcU1n!?-@fB^vH+m`epCS{13(EIp)-fwFGl0kGFC7i zN>(&SyHTmt<4!jdDGpIU6!?z{@P`lU(0~^7?C-a*^t_9No#9v~4uY^9hv+NazFj=L zKE3Ru$D`5sVE^lWa=hCdpACg`mST1|H#DISp}jhQ?_;xV86D4S1lm0k<(z7B1C zFT|z?XEvYB!NFxV!sfFPM{6ICMxWF?HlO?`o6p_?(Gi<61+Zq5mF6`{FA9hPqQFQ2 zIv-qYgr31fBOe{u@f85kK{p#dbL~QO$${t@Of;egP3crbovO?gL+Nz%yC%*vm}u1L zpm=4R$E>W(4Mp+l=yy3Bl&4X8Q9u+(D=?#fmZ|<%^Y8!ZB>5!@hyqikfO3Lb;NzA| zZOv?sYORay5E~iAB^o&eJ5!GNflBcrHZ~k{=>gF*m}o=~n*0%98Ke^h{#1b<&$5wX literal 0 HcmV?d00001 diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f06e3b5da..78d0b2687 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1356,6 +1356,44 @@ paths: summary: List rooms with filters tags: - rooms + /rooms/{id}: + get: + description: Retrieves a single room by its UUID + parameters: + - description: Hotel ID (UUID) + in: header + name: X-Hotel-ID + required: true + type: string + - description: Room ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/RoomWithOptionalGuestBooking' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Get room by ID + tags: + - rooms /rooms/floors: get: description: Retrieves all distinct floor numbers diff --git a/clients/mobile/data/mockTasks.ts b/clients/mobile/data/mockTasks.ts deleted file mode 100644 index 11c2f01d2..000000000 --- a/clients/mobile/data/mockTasks.ts +++ /dev/null @@ -1,99 +0,0 @@ -export type Priority = "High" | "Middle" | "Low"; -export type Department = - | "Housekeeping" - | "Room Service" - | "Maintenance" - | "Front Desk"; - -export interface Task { - id: string; - title: string; - priority: Priority; - department: Department; - location: string; - description?: string; - dueTime?: string; - isAssigned: boolean; -} - -export const myTasks: Task[] = [ - { - id: "1", - title: "Clean Up Spill", - priority: "High", - department: "Housekeeping", - location: "Floor 3, Room 2A", - description: "Carpet cleaner needed", - dueTime: "Today, 11:30am", - isAssigned: true, - }, - { - id: "2", - title: "Vacuum Carpet", - priority: "Middle", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, - { - id: "3", - title: "Vacuum Carpet", - priority: "Low", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, - { - id: "4", - title: "Vacuum Carpet", - priority: "Low", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, - { - id: "5", - title: "Vacuum Carpet", - priority: "Low", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, -]; - -export const unassignedTasks: Task[] = [ - { - id: "6", - title: "Breakfast for VIP", - priority: "High", - department: "Room Service", - location: "Floor 30, Penthouse 2", - description: "Lorem Ipsum dolor sit amet description here...", - isAssigned: false, - }, - { - id: "7", - title: "Cleanup Brunch Cart", - priority: "High", - department: "Room Service", - location: "Floor 30, Penthouse 5", - description: "Lorem Ipsum dolor sit amet description here...", - isAssigned: false, - }, - { - id: "8", - title: "Reheated Towels for Guest", - priority: "Middle", - department: "Room Service", - location: "Floor 4", - isAssigned: false, - }, - { - id: "9", - title: "Steamed Blankets & Pillowcases for Family of 5", - priority: "Middle", - department: "Room Service", - location: "Floor 4", - isAssigned: false, - }, -]; diff --git a/clients/mobile/hooks/use-tasks-feed.ts b/clients/mobile/hooks/use-tasks-feed.ts index d31d00467..0e359a857 100644 --- a/clients/mobile/hooks/use-tasks-feed.ts +++ b/clients/mobile/hooks/use-tasks-feed.ts @@ -1,9 +1,14 @@ import { useInfiniteQuery, - useMutation, useQueryClient, } from "@tanstack/react-query"; -import { API_ENDPOINTS, useAPIClient } from "@shared"; +import { + API_ENDPOINTS, + useAPIClient, + usePatchTasksId, + usePostTasksIdClaim, + usePostTasksIdDrop, +} from "@shared"; import { TAB, type TabName, tabToApi } from "@/constants/tasks"; import type { @@ -51,31 +56,69 @@ export function useTasksFeed(tab: TabName, filters: TasksFilterState) { } export function useTaskMutations() { - const client = useAPIClient(); const qc = useQueryClient(); const invalidate = () => qc.invalidateQueries({ queryKey: ["tasks-feed"] }); - const patchStatus = useMutation({ - mutationFn: async ({ id, status }: { id: string; status: string }) => { - await client.patch(API_ENDPOINTS.task(id), { status }); + const patchStatusMutation = usePatchTasksId({ + mutation: { + onSettled: invalidate, }, - onSettled: invalidate, }); + type PatchStatusInput = { + id: string; + status: Parameters[0]["data"]["status"]; + }; + const patchStatus = { + ...patchStatusMutation, + mutate: ( + vars: PatchStatusInput, + ...args: Parameters extends [any, ...infer R] + ? R + : never + ) => + patchStatusMutation.mutate( + { id: vars.id, data: { status: vars.status } }, + ...args, + ), + mutateAsync: (vars: PatchStatusInput) => + patchStatusMutation.mutateAsync({ + id: vars.id, + data: { status: vars.status }, + }), + }; - const claimTask = useMutation({ - mutationFn: async (id: string) => { - await client.post(API_ENDPOINTS.taskClaim(id), {}); + const claimTaskMutation = usePostTasksIdClaim({ + mutation: { + onSettled: invalidate, }, - onSettled: invalidate, }); + const claimTask = { + ...claimTaskMutation, + mutate: ( + id: string, + ...args: Parameters extends [any, ...infer R] + ? R + : never + ) => claimTaskMutation.mutate({ id }, ...args), + mutateAsync: (id: string) => claimTaskMutation.mutateAsync({ id }), + }; - const dropTask = useMutation({ - mutationFn: async (id: string) => { - await client.post(API_ENDPOINTS.taskDrop(id), {}); + const dropTaskMutation = usePostTasksIdDrop({ + mutation: { + onSettled: invalidate, }, - onSettled: invalidate, }); + const dropTask = { + ...dropTaskMutation, + mutate: ( + id: string, + ...args: Parameters extends [any, ...infer R] + ? R + : never + ) => dropTaskMutation.mutate({ id }, ...args), + mutateAsync: (id: string) => dropTaskMutation.mutateAsync({ id }), + }; return { patchStatus, claimTask, dropTask }; } diff --git a/clients/shared/src/api/endpoints.ts b/clients/shared/src/api/endpoints.ts index a333d2151..8be444b5a 100644 --- a/clients/shared/src/api/endpoints.ts +++ b/clients/shared/src/api/endpoints.ts @@ -1,6 +1,3 @@ export const API_ENDPOINTS = { TASKS: "/tasks", - task: (id: string) => `/tasks/${id}`, - taskClaim: (id: string) => `/tasks/${id}/claim`, - taskDrop: (id: string) => `/tasks/${id}/drop`, } as const; diff --git a/clients/shared/src/api/orval-mutator.ts b/clients/shared/src/api/orval-mutator.ts index 927ca8e5e..b7ba4f1b9 100755 --- a/clients/shared/src/api/orval-mutator.ts +++ b/clients/shared/src/api/orval-mutator.ts @@ -10,10 +10,9 @@ import { RequestConfig } from "../types/api.types"; export const useCustomInstance = (): (( config: RequestConfig, ) => Promise) => { - const { getToken } = getConfig(); - const request = createRequest(getToken, getBaseUrl()); - return async (config: RequestConfig): Promise => { + const { getToken } = getConfig(); + const request = createRequest(getToken, getBaseUrl()); const response = await request(config); return response; }; diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index f5abb075d..156f6cdcb 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -62,6 +62,13 @@ export { useGetRoomsFloors, } from "./api/generated/endpoints/rooms/rooms"; +export { + usePostTasks, + usePatchTasksId, + usePostTasksIdClaim, + usePostTasksIdDrop, +} from "./api/generated/endpoints/tasks/tasks"; + export type { RoomWithOptionalGuestBooking, FilterRoomsRequest, From a43e010cdcfab9c24b4fdb229eef971d8eaaf766 Mon Sep 17 00:00:00 2001 From: Ari Spokony Date: Tue, 7 Apr 2026 16:02:45 -0400 Subject: [PATCH 5/7] fixed wrong if and use of a regerence to string, ran make make format --- backend/docs/swagger.yaml | 6 +++--- backend/internal/handler/auth_context.go | 4 ++-- backend/internal/handler/hotels_test.go | 21 ++++++++++----------- backend/internal/handler/tasks_test.go | 4 ++-- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 78d0b2687..49fbfa177 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -42,7 +42,7 @@ definitions: example: John type: string hotel_id: - example: hotel_123 + example: 550e8400-e29b-41d4-a716-446655440000 type: string id: example: user_123 @@ -299,7 +299,7 @@ definitions: minimum: 1 type: integer id: - example: "550e8400-e29b-41d4-a\t716-446655440000" + example: org_2abc123 type: string name: example: Hotel California @@ -565,7 +565,7 @@ definitions: example: John type: string hotel_id: - example: hotel_123 + example: 550e8400-e29b-41d4-a716-446655440000 type: string id: example: user_123 diff --git a/backend/internal/handler/auth_context.go b/backend/internal/handler/auth_context.go index 0ec950973..a0a724d3a 100644 --- a/backend/internal/handler/auth_context.go +++ b/backend/internal/handler/auth_context.go @@ -30,9 +30,9 @@ func userIDAndHotelFromAuth(c *fiber.Ctx, users authUserLookup) (clerkID, hotelI return "", "", errs.InternalServerError() } - if u.HotelID == nil || strings.TrimSpace(*u.HotelID) == "" { + if strings.TrimSpace(u.HotelID) == "" { return "", "", errs.BadRequest("user has no hotel assigned") } - return clerkID, *u.HotelID, nil + return clerkID, u.HotelID, nil } diff --git a/backend/internal/handler/hotels_test.go b/backend/internal/handler/hotels_test.go index 46261332d..424988efa 100644 --- a/backend/internal/handler/hotels_test.go +++ b/backend/internal/handler/hotels_test.go @@ -83,14 +83,13 @@ func TestHotelHandler_GetHotelByID(t *testing.T) { func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() - floors := 10 validBody := `{ "id": "org_2abc123", "name": "The Grand Budapest Hotel", "floors": 10 }` - newMock := func(returnFloors *int) *mockHotelsRepository { + newMock := func() *mockHotelsRepository { return &mockHotelsRepository{ insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { return &models.Hotel{ @@ -106,7 +105,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New() - h := NewHotelsHandler(newMock(&floors)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(validBody)) @@ -126,7 +125,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{invalid json`)) @@ -141,7 +140,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ @@ -162,7 +161,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ @@ -184,7 +183,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ @@ -205,7 +204,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ @@ -227,7 +226,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ @@ -249,7 +248,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ @@ -271,7 +270,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() app := fiber.New() - h := NewHotelsHandler(newMock(nil)) + h := NewHotelsHandler(newMock()) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ diff --git a/backend/internal/handler/tasks_test.go b/backend/internal/handler/tasks_test.go index 0a092685b..9584a2535 100644 --- a/backend/internal/handler/tasks_test.go +++ b/backend/internal/handler/tasks_test.go @@ -24,7 +24,7 @@ type mockAuthUsers struct { func (m *mockAuthUsers) FindUser(ctx context.Context, id string) (*models.User, error) { if m.findUser == nil { h := "521e8400-e458-41d4-a716-446655440000" - return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: &h}}, nil + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: h}}, nil } return m.findUser(ctx, id) } @@ -63,7 +63,7 @@ func TestTasksHandler_GetTasks(t *testing.T) { t.Parallel() app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{ findUser: func(ctx context.Context, id string) (*models.User, error) { - return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: nil}}, nil + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: ""}}, nil }, }, "user_clerk_1") req := httptest.NewRequest("GET", "/tasks?tab=my", nil) From 5472b83d504625edba214baa22ccecfed932bcd4 Mon Sep 17 00:00:00 2001 From: Ari Spokony Date: Tue, 7 Apr 2026 16:04:42 -0400 Subject: [PATCH 6/7] make format --- .../tasks/task-completion-modal.tsx | 4 +++- .../components/tasks/task-detail-sheet.tsx | 4 +--- clients/mobile/hooks/use-tasks-feed.ts | 24 ++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/clients/mobile/components/tasks/task-completion-modal.tsx b/clients/mobile/components/tasks/task-completion-modal.tsx index fbd9c8752..e02e0668e 100644 --- a/clients/mobile/components/tasks/task-completion-modal.tsx +++ b/clients/mobile/components/tasks/task-completion-modal.tsx @@ -21,7 +21,9 @@ export function TaskCompletionModal({ onPress={onClose} className="bg-blue-600 rounded-lg h-10 px-6 mt-6 items-center justify-center" > - Done + + Done + diff --git a/clients/mobile/components/tasks/task-detail-sheet.tsx b/clients/mobile/components/tasks/task-detail-sheet.tsx index 05d378e2d..2a9e808fc 100644 --- a/clients/mobile/components/tasks/task-detail-sheet.tsx +++ b/clients/mobile/components/tasks/task-detail-sheet.tsx @@ -97,9 +97,7 @@ export function TaskDetailSheet({ }, []); const deadlineDisplay = - task?.dueTime?.trim() && task.dueTime !== "—" - ? task.dueTime - : "Not set"; + task?.dueTime?.trim() && task.dueTime !== "—" ? task.dueTime : "Not set"; return ( [0]["data"]["status"]; + status: Parameters< + typeof patchStatusMutation.mutateAsync + >[0]["data"]["status"]; }; const patchStatus = { ...patchStatusMutation, mutate: ( vars: PatchStatusInput, - ...args: Parameters extends [any, ...infer R] + ...args: Parameters extends [ + any, + ...infer R, + ] ? R : never ) => @@ -97,7 +99,10 @@ export function useTaskMutations() { ...claimTaskMutation, mutate: ( id: string, - ...args: Parameters extends [any, ...infer R] + ...args: Parameters extends [ + any, + ...infer R, + ] ? R : never ) => claimTaskMutation.mutate({ id }, ...args), @@ -113,7 +118,10 @@ export function useTaskMutations() { ...dropTaskMutation, mutate: ( id: string, - ...args: Parameters extends [any, ...infer R] + ...args: Parameters extends [ + any, + ...infer R, + ] ? R : never ) => dropTaskMutation.mutate({ id }, ...args), From 4d1760ce7b80229c3ce1eab50c0e3bdf7f6123b5 Mon Sep 17 00:00:00 2001 From: Dao Ho <84757503+Dao-Ho@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:58:50 -0400 Subject: [PATCH 7/7] sync swagger and orval --- backend/docs/swagger.yaml | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 3715e5a6a..2e212d9cc 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -680,6 +680,25 @@ definitions: name: type: string type: object + github_com_generate_selfserve_internal_models.CreateTaskBody: + properties: + assign_to_me: + type: boolean + department: + type: string + description: + type: string + name: + type: string + priority: + enum: + - low + - medium + - high + type: string + required: + - name + type: object github_com_generate_selfserve_internal_models.FloorSortOrder: enum: - ascending @@ -704,6 +723,18 @@ definitions: x-enum-varnames: - TypeTaskAssigned - TypeHighPriorityTask + github_com_generate_selfserve_internal_models.PatchTaskBody: + properties: + status: + enum: + - pending + - assigned + - in progress + - completed + type: string + required: + - status + type: object github_com_generate_selfserve_internal_models.RequestSortOrder: enum: - high_to_low @@ -714,6 +745,27 @@ definitions: - RequestSortHighToLow - RequestSortLowToHigh - RequestSortUrgent + github_com_generate_selfserve_internal_models.Task: + properties: + department: + type: string + description: + type: string + due_time: + type: string + id: + type: string + is_assigned: + type: boolean + location: + type: string + priority: + type: string + status: + type: string + title: + type: string + type: object github_com_generate_selfserve_internal_models.UpdateDepartment: properties: name: @@ -731,6 +783,18 @@ definitions: description: nil when no more pages type: string type: object + github_com_generate_selfserve_internal_utils.CursorPage-github_com_generate_selfserve_internal_models_Task: + properties: + has_more: + type: boolean + items: + items: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.Task' + type: array + next_cursor: + description: nil when no more pages + type: string + type: object internal_handler.AddEmployeeDepartmentBody: properties: department_id: