diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index bcece634..042c326e 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -80,6 +80,25 @@ definitions: example: America/New_York type: string type: object + CreateViewInput: + properties: + display_name: + type: string + filters: + items: + type: integer + type: array + slug: + allOf: + - $ref: '#/definitions/github_com_generate_selfserve_internal_models.ViewSlug' + enum: + - requests_web + - rooms_web + - guests_web + required: + - filters + - slug + type: object Department: properties: created_at: @@ -554,6 +573,8 @@ definitions: - in progress - completed type: string + unassign: + type: boolean user_id: type: string type: object @@ -691,6 +712,25 @@ definitions: $ref: '#/definitions/User' type: array type: object + View: + properties: + created_at: + type: string + display_name: + type: string + filters: + items: + type: integer + type: array + id: + type: string + slug: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.ViewSlug' + updated_at: + type: string + user_id: + type: string + type: object github_com_generate_selfserve_internal_errs.HTTPError: properties: code: @@ -751,6 +791,16 @@ definitions: name: type: string type: object + github_com_generate_selfserve_internal_models.ViewSlug: + enum: + - requests_web + - rooms_web + - guests_web + type: string + x-enum-varnames: + - ViewSlugRequestsWeb + - ViewSlugRoomsWeb + - ViewSlugGuestsWeb github_com_generate_selfserve_internal_utils.CursorPage-GuestRequest: properties: has_more: @@ -1612,6 +1662,78 @@ paths: summary: generates a request tags: - requests + /request/generate/async: + post: + consumes: + - application/json + description: Starts async request generation via Temporal workflow + parameters: + - description: Request data with raw text + in: body + name: request + required: true + schema: + $ref: '#/definitions/GenerateRequestInput' + produces: + - application/json + responses: + "202": + description: Accepted + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: starts request generation workflow + tags: + - requests + /request/generate/async/{workflowId}: + get: + description: Gets async request generation workflow status/result + parameters: + - description: Workflow ID + in: path + name: workflowId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: gets request generation workflow status + tags: + - requests /request/guest/{id}: get: description: Retrieves all requests for a given guest @@ -2336,6 +2458,95 @@ paths: summary: Search users by hotel tags: - users + /views: + get: + description: Returns all saved filter views for the authenticated user scoped + to a page slug + parameters: + - description: Page slug (e.g. requests_web) + in: query + name: slug + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/View' + type: array + "400": + description: Bad Request + 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: List views + tags: + - views + post: + consumes: + - application/json + description: Saves the current filter state as a named view for the authenticated + user + parameters: + - description: View payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/CreateViewInput' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/View' + "400": + description: Bad Request + 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: Create view + tags: + - views + /views/{id}: + delete: + description: Deletes a saved filter view owned by the authenticated user + parameters: + - description: View ID + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "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: Delete view + tags: + - views schemes: - http - https diff --git a/clients/mobile/app.json b/clients/mobile/app.json index 435dd587..e90ba75e 100644 --- a/clients/mobile/app.json +++ b/clients/mobile/app.json @@ -45,6 +45,13 @@ "color": "#ffffff", "sounds": [] } + ], + [ + "expo-image-picker", + { + "photosPermission": "Allow SelfServe to access your photos to set your profile picture.", + "cameraPermission": "Allow SelfServe to use the camera for your profile picture." + } ] ], "experiments": { diff --git a/clients/mobile/app/(tabs)/profile.tsx b/clients/mobile/app/(tabs)/profile.tsx index febf818f..9ab86a2d 100644 --- a/clients/mobile/app/(tabs)/profile.tsx +++ b/clients/mobile/app/(tabs)/profile.tsx @@ -6,10 +6,18 @@ import { useGetUser } from "@shared"; import LogoutButton from "@/components/Logout"; import { ProfileHero } from "@/components/profile/ProfileHero"; import { ProfileInfoCard } from "@/components/profile/ProfileInfoCard"; +import { useProfilePicture } from "@/hooks/use-profile-picture"; export default function Profile() { const { userId } = useAuth(); const { data: user, isLoading } = useGetUser(userId ?? undefined); + const { + profilePicUrl, + status, + isLoading: isPicLoading, + isInitialLoading: isPicInitialLoading, + pickAndUpload, + } = useProfilePicture(userId ?? undefined); const onSignOut = () => { router.replace("/sign-in"); @@ -37,8 +45,19 @@ export default function Profile() { void pickAndUpload()} + isAvatarBusy={isPicLoading} /> + {status.startsWith("Error") ? ( + + {status} + + ) : null} void; + isAvatarBusy?: boolean; }; export function ProfileHero({ firstName, lastName, avatarUrl, + onAvatarPress, + isAvatarBusy = false, }: ProfileHeroProps) { + const [isPreviewVisible, setIsPreviewVisible] = useState(false); + const shouldSkipNextPressRef = useRef(false); + const closePreview = () => { + shouldSkipNextPressRef.current = false; + setIsPreviewVisible(false); + }; const displayName = [firstName, lastName].filter(Boolean).join(" ") || "User"; const initial = displayName.charAt(0).toUpperCase(); - return ( - + const avatarInner = ( + {avatarUrl ? ( - + ) : ( - + {initial} )} + + ); + + return ( + + {onAvatarPress ? ( + { + if (shouldSkipNextPressRef.current) { + shouldSkipNextPressRef.current = false; + return; + } + onAvatarPress(); + }} + onLongPress={() => { + if (!avatarUrl || isAvatarBusy) return; + shouldSkipNextPressRef.current = true; + setIsPreviewVisible(true); + }} + disabled={isAvatarBusy} + delayLongPress={220} + className="relative" + accessibilityRole="button" + accessibilityLabel="Change profile photo" + > + {avatarInner} + {isAvatarBusy ? ( + + + + ) : null} + {!isAvatarBusy ? ( + + + + ) : null} + + ) : ( + avatarInner + )} + {avatarUrl ? ( + + + {}}> + + + + + + + ) : null} {displayName} diff --git a/clients/mobile/hooks/use-profile-picture.ts b/clients/mobile/hooks/use-profile-picture.ts new file mode 100644 index 00000000..f28ef2a7 --- /dev/null +++ b/clients/mobile/hooks/use-profile-picture.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import * as ImagePicker from "expo-image-picker"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAPIClient } from "@shared/api/client"; +import { + getExtFromMime, + getProfilePicture, + getUploadUrl, + getUserQueryKey, + saveProfilePictureKey, + uploadToS3PresignedPut, +} from "@shared"; + +import { StartupStatus, useStartup } from "@/context/startup"; + +async function readPickedImage( + uri: string, + mimeHint: string | null | undefined, +): Promise<{ body: ArrayBuffer; contentType: string }> { + const res = await fetch(uri); + if (!res.ok) { + throw new Error("Could not read the selected image"); + } + const body = await res.arrayBuffer(); + const fromHeader = res.headers.get("content-type")?.split(";")[0]?.trim(); + const contentType = + (mimeHint && mimeHint.length > 0 ? mimeHint : undefined) || + (fromHeader && fromHeader.length > 0 ? fromHeader : undefined) || + "image/jpeg"; + return { body, contentType }; +} + +export function useProfilePicture(userId: string | undefined): { + profilePicUrl: string | null; + status: string; + isLoading: boolean; + isInitialLoading: boolean; + pickAndUpload: () => Promise; +} { + const startupStatus = useStartup(); + const api = useAPIClient(); + const apiRef = useRef(api); + apiRef.current = api; + const queryClient = useQueryClient(); + + const [profilePicUrl, setProfilePicUrl] = useState(null); + const [status, setStatus] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isInitialLoading, setIsInitialLoading] = useState(!!userId); + + useEffect(() => { + if (!userId) { + setProfilePicUrl(null); + setIsInitialLoading(false); + return; + } + if (startupStatus !== StartupStatus.Ready) { + if (startupStatus !== StartupStatus.Loading) { + setIsInitialLoading(false); + } + return; + } + + const ac = new AbortController(); + setIsInitialLoading(true); + void (async () => { + try { + const data = await getProfilePicture(apiRef.current, userId); + if (!ac.signal.aborted) { + setProfilePicUrl(data?.presigned_url ?? null); + } + } finally { + if (!ac.signal.aborted) { + setIsInitialLoading(false); + } + } + })(); + return () => ac.abort(); + }, [userId, startupStatus]); + + const pickAndUpload = useCallback(async () => { + if (!userId || startupStatus !== StartupStatus.Ready) { + return; + } + + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + setStatus("Error: Photo library permission is required."); + return; + } + + const picked = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + aspect: [1, 1], + quality: 0.85, + }); + + if (picked.canceled || !picked.assets[0]) { + return; + } + + const asset = picked.assets[0]; + const uri = asset.uri; + const mimeType = asset.mimeType ?? undefined; + + setIsLoading(true); + setStatus(""); + try { + const { body, contentType } = await readPickedImage(uri, mimeType); + const ext = getExtFromMime(contentType); + + const { presigned_url, key } = await getUploadUrl(api, userId, ext); + + await uploadToS3PresignedPut(presigned_url, body, contentType); + + await saveProfilePictureKey(api, userId, key); + + const refreshed = await getProfilePicture(api, userId); + setProfilePicUrl(refreshed?.presigned_url ?? null); + await queryClient.invalidateQueries({ + queryKey: getUserQueryKey(userId), + }); + } catch (err) { + setStatus( + `Error: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } finally { + setIsLoading(false); + } + }, [api, userId, startupStatus, queryClient]); + + return { + profilePicUrl, + status, + isLoading, + isInitialLoading, + pickAndUpload, + }; +} diff --git a/clients/mobile/package-lock.json b/clients/mobile/package-lock.json index a2cc9e48..aebbb428 100644 --- a/clients/mobile/package-lock.json +++ b/clients/mobile/package-lock.json @@ -23,6 +23,7 @@ "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", "expo-notifications": "^0.32.16", "expo-router": "~6.0.21", @@ -7667,6 +7668,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", + "integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "license": "MIT", diff --git a/clients/mobile/package.json b/clients/mobile/package.json index a0bb953a..abd2331d 100644 --- a/clients/mobile/package.json +++ b/clients/mobile/package.json @@ -30,6 +30,7 @@ "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", "expo-notifications": "^0.32.16", "expo-router": "~6.0.21", diff --git a/clients/shared/src/api/profile-picture.ts b/clients/shared/src/api/profile-picture.ts index f893476b..08fd8398 100644 --- a/clients/shared/src/api/profile-picture.ts +++ b/clients/shared/src/api/profile-picture.ts @@ -64,4 +64,21 @@ export async function uploadFileToS3( if (!res.ok) { throw new Error("Failed to upload to S3"); } +} + +export async function uploadToS3PresignedPut( + presignedUrl: string, + body: ArrayBuffer, + contentType: string, +): Promise { + const res = await fetch(presignedUrl, { + method: "PUT", + body, + headers: { + "Content-Type": contentType, + }, + }); + if (!res.ok) { + throw new Error("Failed to upload to S3"); + } } \ No newline at end of file diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index 87c0fabd..604234fd 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -13,6 +13,7 @@ export { saveProfilePictureKey, deleteProfilePicture, uploadFileToS3, + uploadToS3PresignedPut, } from "./api/profile-picture"; export type { UploadUrlResponse,