diff --git a/libertas-frontend/src/components/sharing/share-dialog.tsx b/libertas-frontend/src/components/sharing/share-dialog.tsx new file mode 100644 index 0000000..bcf8c8c --- /dev/null +++ b/libertas-frontend/src/components/sharing/share-dialog.tsx @@ -0,0 +1,188 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { searchUsers } from "@/services/user-service"; +import type { User } from "@/domain/types"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "@/hooks/use-debounce"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Loader2, X } from "lucide-react"; + +export type SharePermission = "view" | "contribute" | "can_use"; + +interface ShareDialogProps { + title: string; + trigger: React.ReactNode; + currentShares: { user: User; permission: SharePermission }[]; + onAddShare: (userId: string, permission: SharePermission) => Promise; + onRemoveShare: (userId: string) => Promise; + permissionOptions: { value: SharePermission; label: string }[]; +} + +export function ShareDialog({ + title, + trigger, + currentShares, + onAddShare, + onRemoveShare, + permissionOptions, +}: ShareDialogProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedPermission, setSelectedPermission] = useState( + permissionOptions[0].value + ); + const debouncedQuery = useDebounce(searchQuery, 300); + + const { data: searchResults, isLoading } = useQuery({ + queryKey: ["users", "search", debouncedQuery], + queryFn: () => searchUsers(debouncedQuery), + enabled: debouncedQuery.length > 1, + }); + + const handleAdd = async (user: User) => { + await onAddShare(user.id, selectedPermission); + setSearchQuery(""); // Clear search after adding + }; + + return ( + + {trigger} + + + {title} + +
+
+ setSearchQuery(e.target.value)} + className="flex-1" + /> + +
+ + {/* Search Results */} + {searchQuery.length > 1 && ( +
+ {isLoading ? ( +
+ +
+ ) : searchResults && searchResults.length > 0 ? ( + searchResults.map((user) => { + const isAlreadyShared = currentShares.some( + (s) => s.user.id === user.id + ); + return ( +
+
+ + + {user.username.substring(0, 2).toUpperCase()} + + +
+ + {user.username} + + + {user.email} + +
+
+ +
+ ); + }) + ) : ( +

+ No users found. +

+ )} +
+ )} + +
+

Shared with

+ {currentShares.length === 0 ? ( +

Not shared with anyone yet.

+ ) : ( +
+ {currentShares.map((share) => ( +
+
+ + + {share.user.username.substring(0, 2).toUpperCase()} + + +
+

+ {share.user.username} +

+

+ {share.permission} +

+
+
+ +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/libertas-frontend/src/features/albums/use-albums.ts b/libertas-frontend/src/features/albums/use-albums.ts index 88f6ae1..f8c9945 100644 --- a/libertas-frontend/src/features/albums/use-albums.ts +++ b/libertas-frontend/src/features/albums/use-albums.ts @@ -9,7 +9,9 @@ import { removeMediaFromAlbum, setAlbumThumbnail, shareAlbum, + unshareAlbum, updateAlbum, + getAlbumShares, type AddMediaToAlbumPayload, type RemoveMediaFromAlbumPayload, type SetAlbumThumbnailPayload, @@ -149,17 +151,36 @@ export const useRemoveMediaFromAlbum = (albumId: string) => { * Mutation hook to share an album with another user. */ export const useShareAlbum = (albumId: string) => { - // const queryClient = useQueryClient(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload), onSuccess: () => { - // Invalidate sharing info (when we add that query) - // queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] }); - // TODO: Add success toast + queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] }); }, }); }; +export const useUnshareAlbum = (albumId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (targetUserId: string) => unshareAlbum(albumId, targetUserId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] }); + }, + }); +}; + +/** + * Query hook to fetch shares for an album. + */ +export const useGetAlbumShares = (albumId: string) => { + return useQuery({ + queryKey: [ALBUMS_KEY, "details", albumId, "shares"], + queryFn: () => getAlbumShares(albumId), + enabled: !!albumId, + }); +}; + /** * Mutation hook to set an album's thumbnail. */ diff --git a/libertas-frontend/src/features/people/use-people.ts b/libertas-frontend/src/features/people/use-people.ts index 594ef5d..016950e 100644 --- a/libertas-frontend/src/features/people/use-people.ts +++ b/libertas-frontend/src/features/people/use-people.ts @@ -16,6 +16,7 @@ import { unsharePerson, updatePerson, clusterFaces, + getPersonShares, type CreatePersonPayload, type MergePersonPayload, type SetPersonThumbnailPayload, @@ -45,7 +46,7 @@ export const useGetPerson = (personId: string) => { export const useListPersonMedia = (personId: string) => { return useInfiniteQuery({ queryKey: [PERSON_KEY, "details", personId, "media"], - queryFn: ({ pageParam = 1 }) => listMediaForPerson({personId, page: pageParam, limit: 20} ), + queryFn: ({ pageParam = 1 }) => listMediaForPerson({ personId, page: pageParam, limit: 20 }), getNextPageParam: (lastPage) => { return lastPage.has_next_page ? lastPage.page + 1 : undefined; }, @@ -64,6 +65,34 @@ export const useCreatePerson = () => { }); }; +export const useClusterFaces = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: clusterFaces, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PERSON_KEY] }); + }, + }); +}; + +export const useSharePerson = (personId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: SharePersonPayload) => sharePerson(personId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "details", personId, "shares"] }); + }, + }); +}; + +export const useGetPersonShares = (personId: string) => { + return useQuery({ + queryKey: [PERSON_KEY, "details", personId, "shares"], + queryFn: () => getPersonShares(personId), + enabled: !!personId, + }); +}; + export const useUpdatePerson = (personId: string) => { const queryClient = useQueryClient(); return useMutation({ @@ -95,16 +124,14 @@ export const useDeletePerson = (personId: string) => { }); }; -export const useSharePerson = (personId: string) => { - return useMutation({ - mutationFn: (payload: SharePersonPayload) => sharePerson(personId, payload), - }); -}; - export const useUnsharePerson = (personId: string) => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: UnsharePersonPayload) => - unsharePerson(personId, payload), + unsharePerson(personId, payload.target_user_id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "details", personId, "shares"] }); + }, }); }; @@ -131,10 +158,4 @@ export const useSetPersonThumbnail = (personId: string) => { queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "list"] }); }, }); -}; - -export const useClusterFaces = () => { - return useMutation({ - mutationFn: clusterFaces, - }); }; \ No newline at end of file diff --git a/libertas-frontend/src/hooks/use-debounce.ts b/libertas-frontend/src/hooks/use-debounce.ts new file mode 100644 index 0000000..eb328eb --- /dev/null +++ b/libertas-frontend/src/hooks/use-debounce.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/libertas-frontend/src/routes/albums/$albumId.tsx b/libertas-frontend/src/routes/albums/$albumId.tsx index 7cd66cf..455adca 100644 --- a/libertas-frontend/src/routes/albums/$albumId.tsx +++ b/libertas-frontend/src/routes/albums/$albumId.tsx @@ -1,6 +1,8 @@ import { AddMediaToAlbumDialog } from "@/components/albums/add-media-to-album-dialog"; import { AuthenticatedImage } from "@/components/media/authenticated-image"; import { MediaViewer } from "@/components/media/media-viewer"; +import { ShareDialog, type SharePermission } from "@/components/sharing/share-dialog"; +import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuContent, @@ -11,10 +13,13 @@ import type { Media } from "@/domain/types"; import { useGetAlbum, useGetAlbumMedia, + useGetAlbumShares, useRemoveMediaFromAlbum, + useShareAlbum, + useUnshareAlbum, } from "@/features/albums/use-albums"; import { createFileRoute } from "@tanstack/react-router"; -import { Eye, Trash2 } from "lucide-react"; +import { Eye, Share2, Trash2 } from "lucide-react"; import { useState } from "react"; export const Route = createFileRoute("/albums/$albumId")({ @@ -33,6 +38,10 @@ function AlbumDetailPage() { isLoading: isLoadingMedia, error: mediaError, } = useGetAlbumMedia(albumId); + const { data: shares } = useGetAlbumShares(albumId); + const { mutateAsync: shareAlbum } = useShareAlbum(albumId); + const { mutateAsync: unshareAlbum } = useUnshareAlbum(albumId); + const [selectedMedia, setSelectedMedia] = useState(null); const { mutate: removeMedia, isPending: isRemoving } = @@ -42,18 +51,57 @@ function AlbumDetailPage() { const error = albumError || mediaError; const handleRemoveMedia = (mediaId: string) => { - removeMedia({ - media_ids: [mediaId], + if (confirm("Are you sure you want to remove this photo from the album?")) { + removeMedia({ + media_ids: [mediaId], + }); + } + }; + + const handleAddShare = async (userId: string, permission: SharePermission) => { + await shareAlbum({ + target_user_id: userId, + permission: permission === "can_use" ? "contribute" : "view", }); }; + const handleRemoveShare = async (userId: string) => { + if (confirm("Are you sure you want to remove this share?")) { + await unshareAlbum(userId); + } + }; + + const currentShares = + shares?.map((s) => ({ + user: s.user, + permission: s.permission === "contribute" ? "can_use" : ("view" as SharePermission), + })) ?? []; + return (

{album?.name ?? "Loading album..."}

- +
+ + + Share + + } + currentShares={currentShares} + onAddShare={handleAddShare} + onRemoveShare={handleRemoveShare} + permissionOptions={[ + { value: "view", label: "Can View" }, + { value: "can_use", label: "Can Contribute" }, + ]} + /> + +
{isLoading &&

Loading photos...

} diff --git a/libertas-frontend/src/routes/people/$personId.tsx b/libertas-frontend/src/routes/people/$personId.tsx index b511ea1..5dfd0c1 100644 --- a/libertas-frontend/src/routes/people/$personId.tsx +++ b/libertas-frontend/src/routes/people/$personId.tsx @@ -1,11 +1,19 @@ import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { useGetPerson, useListPersonMedia } from "@/features/people/use-people"; +import { ShareDialog, type SharePermission } from "@/components/sharing/share-dialog"; +import { + useGetPerson, + useGetPersonShares, + useListPersonMedia, + useSharePerson, + useUnsharePerson, + useUpdatePerson, +} from "@/features/people/use-people"; import { AuthenticatedImage } from "@/components/media/authenticated-image"; import { MediaViewer } from "@/components/media/media-viewer"; import { Button } from "@/components/ui/button"; import { type Media } from "@/domain/types"; -import { EditPersonDialog } from "@/components/people/edit-person-dialog"; +import { Share2 } from "lucide-react"; export const Route = createFileRoute("/people/$personId")({ component: PersonDetailPage, @@ -15,27 +23,100 @@ function PersonDetailPage() { const { personId } = Route.useParams(); const { data: person, isLoading: isLoadingPerson } = useGetPerson(personId); const { - data: mediaPages, - isLoading: isLoadingMedia, + data: mediaPage, fetchNextPage, hasNextPage, isFetchingNextPage, + isLoading: isLoadingMedia, } = useListPersonMedia(personId); + const { data: shares } = useGetPersonShares(personId); + const { mutateAsync: sharePerson } = useSharePerson(personId); + const { mutateAsync: unsharePerson } = useUnsharePerson(personId); + const { mutate: updatePerson } = useUpdatePerson(personId); + const [isEditingName, setIsEditingName] = useState(false); + const [newName, setNewName] = useState(""); const [selectedMedia, setSelectedMedia] = useState(null); - const allMedia = mediaPages?.pages.flatMap((page) => page.data) ?? []; + const handleNameSave = () => { + if (newName && newName !== person?.name) { + updatePerson({ name: newName }); + } + setIsEditingName(false); + }; + + const handleAddShare = async (userId: string, permission: SharePermission) => { + await sharePerson({ + target_user_id: userId, + permission: permission === "can_use" ? "can_use" : "view", + }); + }; + + const handleRemoveShare = async (userId: string) => { + if (confirm("Are you sure you want to remove this share?")) { + await unsharePerson({ target_user_id: userId }); + } + }; + + const currentShares = + shares?.map((s) => ({ + user: s.user, + permission: s.permission === "can_use" ? "can_use" : ("view" as SharePermission), + })) ?? []; + + if (isLoadingPerson) return
Loading person...
; + if (!person) return
Person not found
; + + const allMedia = mediaPage?.pages.flatMap((page) => page.data) ?? []; return (
-
-

- {person?.name ?? "Loading person..."} -

- {person && } +
+ {isEditingName ? ( +
+ setNewName(e.target.value)} + autoFocus + /> + + +
+ ) : ( +

{ + setNewName(person.name); + setIsEditingName(true); + }} + title="Click to edit name" + > + {person.name} +

+ )} + + + + Share + + } + currentShares={currentShares} + onAddShare={handleAddShare} + onRemoveShare={handleRemoveShare} + permissionOptions={[ + { value: "view", label: "Can View" }, + { value: "can_use", label: "Can Use (Assign Faces)" }, + ]} + />
- {(isLoadingPerson || isLoadingMedia) && !mediaPages && ( + {(isLoadingPerson || isLoadingMedia) && !mediaPage && (

Loading photos...

)} diff --git a/libertas-frontend/src/services/album-service.ts b/libertas-frontend/src/services/album-service.ts index acf10b7..6b9bff6 100644 --- a/libertas-frontend/src/services/album-service.ts +++ b/libertas-frontend/src/services/album-service.ts @@ -1,4 +1,4 @@ -import type { Album, AlbumPermission, Media } from "@/domain/types"; +import type { Album, AlbumPermission, Media, User } from "@/domain/types"; import apiClient from "@/services/api-client"; import { processMediaUrls } from "./media-service"; @@ -87,9 +87,28 @@ export const shareAlbum = async ( await apiClient.post(`/albums/${albumId}/share`, payload); }; +export const unshareAlbum = async ( + albumId: string, + targetUserId: string, +): Promise => { + await apiClient.delete(`/albums/${albumId}/share`, { + data: { target_user_id: targetUserId }, + }); +}; + export const setAlbumThumbnail = async ( albumId: string, payload: SetAlbumThumbnailPayload, ): Promise => { await apiClient.put(`/albums/${albumId}/thumbnail`, payload); +}; + +export type AlbumShare = { + user: User; + permission: AlbumPermission; +}; + +export const getAlbumShares = async (albumId: string): Promise => { + const { data } = await apiClient.get(`/albums/${albumId}/share`); + return data; }; \ No newline at end of file diff --git a/libertas-frontend/src/services/person-service.ts b/libertas-frontend/src/services/person-service.ts index f3050bb..9cd85a3 100644 --- a/libertas-frontend/src/services/person-service.ts +++ b/libertas-frontend/src/services/person-service.ts @@ -3,6 +3,7 @@ import type { PaginatedResponse, Person, PersonPermission, + User, } from "@/domain/types"; import apiClient from "@/services/api-client"; import { processMediaUrls } from "./media-service"; // We can import the helper @@ -35,7 +36,7 @@ export type SetPersonThumbnailPayload = { }; export type ListPeopleParams = { - personId: string, + personId: string, page: number; limit: number; }; @@ -88,11 +89,15 @@ export const sharePerson = async ( await apiClient.post(`/people/${personId}/share`, payload); }; + + export const unsharePerson = async ( personId: string, - payload: UnsharePersonPayload, + targetUserId: string, ): Promise => { - await apiClient.delete(`/people/${personId}/share`, { data: payload }); + await apiClient.delete(`/people/${personId}/share`, { + data: { target_user_id: targetUserId }, + }); }; export const mergePerson = async ( @@ -109,6 +114,16 @@ export const setPersonThumbnail = async ( await apiClient.put(`/people/${personId}/thumbnail`, payload); }; +export type PersonShare = { + user: User; + permission: PersonPermission; +}; + +export const getPersonShares = async (personId: string): Promise => { + const { data } = await apiClient.get(`/people/${personId}/share`); + return data; +}; + export const clusterFaces = async (): Promise => { await apiClient.post("/people/cluster"); }; \ No newline at end of file diff --git a/libertas-frontend/src/services/user-service.ts b/libertas-frontend/src/services/user-service.ts index e241c24..2caa5d2 100644 --- a/libertas-frontend/src/services/user-service.ts +++ b/libertas-frontend/src/services/user-service.ts @@ -7,4 +7,14 @@ import apiClient from "@/services/api-client"; export const getMe = async (): Promise => { const { data } = await apiClient.get("/users/me"); return data; +}; + +/** + * Searches for users by username or email. + */ +export const searchUsers = async (query: string): Promise => { + const { data } = await apiClient.get("/users/search", { + params: { query }, + }); + return data; }; \ No newline at end of file diff --git a/libertas_api/src/handlers/album_handlers.rs b/libertas_api/src/handlers/album_handlers.rs index c6bef9f..b286ebd 100644 --- a/libertas_api/src/handlers/album_handlers.rs +++ b/libertas_api/src/handlers/album_handlers.rs @@ -83,6 +83,46 @@ async fn list_user_albums( Ok(Json(response)) } +async fn unshare_album( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, + Json(payload): Json, +) -> Result { + state + .album_service + .unshare_album(album_id, payload.target_user_id, user_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_album_shares( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, +) -> Result>, ApiError> { + let shares = state + .album_service + .get_album_shares(album_id, user_id) + .await?; + + let response = shares + .into_iter() + .map(|(user, permission)| crate::schema::AlbumShareResponse { + user: crate::schema::UserResponse { + id: user.id, + username: user.username, + email: user.email, + storage_used: user.storage_used, + storage_quota: user.storage_quota, + }, + permission, + }) + .collect(); + + Ok(Json(response)) +} + async fn get_album_details( State(state): State, UserId(user_id): UserId, @@ -179,5 +219,10 @@ pub fn album_routes() -> Router { .get(get_media_for_album) .delete(remove_media_from_album), ) - .route("/{id}/share", post(share_album)) + .route( + "/{id}/share", + post(share_album) + .get(list_album_shares) + .delete(unshare_album), + ) } diff --git a/libertas_api/src/handlers/person_handlers.rs b/libertas_api/src/handlers/person_handlers.rs index 001c9d5..6c6c83c 100644 --- a/libertas_api/src/handlers/person_handlers.rs +++ b/libertas_api/src/handlers/person_handlers.rs @@ -29,7 +29,9 @@ pub fn people_routes() -> Router { ) .route( "/{person_id}/share", - post(share_person).delete(unshare_person), + post(share_person) + .delete(unshare_person) + .get(list_person_shares), ) .route("/{person_id}/merge", post(merge_person)) .route("/{person_id}/thumbnail", put(set_person_thumbnail)) @@ -120,7 +122,7 @@ async fn unshare_person( State(state): State, UserId(user_id): UserId, Path(person_id): Path, - Json(payload): Json, + Json(payload): Json, ) -> Result { state .person_service @@ -129,6 +131,33 @@ async fn unshare_person( Ok(StatusCode::NO_CONTENT) } +async fn list_person_shares( + State(state): State, + UserId(user_id): UserId, + Path(person_id): Path, +) -> Result>, ApiError> { + let shares = state + .person_service + .get_person_shares(person_id, user_id) + .await?; + + let response = shares + .into_iter() + .map(|(user, permission)| crate::schema::PersonShareResponse { + user: crate::schema::UserResponse { + id: user.id, + username: user.username, + email: user.email, + storage_used: user.storage_used, + storage_quota: user.storage_quota, + }, + permission, + }) + .collect(); + + Ok(Json(response)) +} + async fn list_faces_for_media( State(state): State, UserId(user_id): UserId, diff --git a/libertas_api/src/handlers/user_handlers.rs b/libertas_api/src/handlers/user_handlers.rs index 6e42a9a..9a2cc28 100644 --- a/libertas_api/src/handlers/user_handlers.rs +++ b/libertas_api/src/handlers/user_handlers.rs @@ -18,6 +18,33 @@ pub async fn get_me( Ok(Json(response)) } -pub fn user_routes() -> Router { - Router::new().route("/me", axum::routing::get(get_me)) +#[derive(serde::Deserialize)] +pub struct SearchUserQuery { + query: String, +} + +pub async fn search_users( + State(state): State, + axum::extract::Query(params): axum::extract::Query, +) -> Result>, ApiError> { + let users = state.user_service.search_users(¶ms.query).await?; + + let response = users + .into_iter() + .map(|user| UserResponse { + id: user.id, + username: user.username, + email: user.email, + storage_used: user.storage_used, + storage_quota: user.storage_quota, + }) + .collect(); + + Ok(Json(response)) +} + +pub fn user_routes() -> Router { + Router::new() + .route("/me", axum::routing::get(get_me)) + .route("/search", axum::routing::get(search_users)) } diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index 2ffb73e..d2134f7 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -66,6 +66,17 @@ pub struct ShareAlbumRequest { pub permission: AlbumPermission, } +#[derive(Deserialize)] +pub struct UnshareAlbumRequest { + pub target_user_id: Uuid, +} + +#[derive(Serialize)] +pub struct AlbumShareResponse { + pub user: UserResponse, + pub permission: AlbumPermission, +} + #[derive(Serialize)] pub struct AlbumResponse { pub id: Uuid, @@ -246,12 +257,23 @@ pub struct SharePersonRequest { pub permission: PersonPermission, } +#[derive(Deserialize)] +pub struct UnsharePersonRequest { + pub target_user_id: Uuid, +} + #[derive(Serialize)] pub struct PublicAlbumBundleResponse { pub album: AlbumResponse, pub media: Vec, } +#[derive(Serialize)] +pub struct PersonShareResponse { + pub user: UserResponse, + pub permission: PersonPermission, +} + #[derive(Deserialize)] pub struct MergePersonRequest { pub source_person_id: Uuid, diff --git a/libertas_api/src/services/album_service.rs b/libertas_api/src/services/album_service.rs index e5146f5..c3c9cf4 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -213,4 +213,36 @@ impl AlbumService for AlbumServiceImpl { .remove_media_from_album(album_id, media_ids) .await } + + async fn get_album_shares( + &self, + album_id: Uuid, + user_id: Uuid, + ) -> CoreResult< + Vec<( + libertas_core::models::User, + libertas_core::models::AlbumPermission, + )>, + > { + self.auth_service + .check_permission(Some(user_id), Permission::ShareAlbum(album_id)) + .await?; + + self.album_share_repo.list_shares_for_album(album_id).await + } + + async fn unshare_album( + &self, + album_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()> { + self.auth_service + .check_permission(Some(owner_id), Permission::ShareAlbum(album_id)) + .await?; + + self.album_share_repo + .remove_share(album_id, target_user_id) + .await + } } diff --git a/libertas_api/src/services/person_service.rs b/libertas_api/src/services/person_service.rs index 9e964f4..4016292 100644 --- a/libertas_api/src/services/person_service.rs +++ b/libertas_api/src/services/person_service.rs @@ -187,21 +187,6 @@ impl PersonService for PersonServiceImpl { .await } - async fn unshare_person( - &self, - person_id: Uuid, - target_user_id: Uuid, - owner_id: Uuid, - ) -> CoreResult<()> { - self.auth_service - .check_permission(Some(owner_id), authz::Permission::SharePerson(person_id)) - .await?; - - self.person_share_repo - .remove_share(person_id, target_user_id) - .await - } - async fn merge_people( &self, target_person_id: Uuid, @@ -342,4 +327,33 @@ impl PersonService for PersonServiceImpl { let response = PaginatedResponse::new(data, pagination.page, pagination.limit, total_items); Ok(response) } + + async fn get_person_shares( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult> { + self.auth_service + .check_permission(Some(user_id), authz::Permission::SharePerson(person_id)) + .await?; + + self.person_share_repo + .list_shares_for_person(person_id) + .await + } + + async fn unshare_person( + &self, + person_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()> { + self.auth_service + .check_permission(Some(owner_id), authz::Permission::SharePerson(person_id)) + .await?; + + self.person_share_repo + .remove_share(person_id, target_user_id) + .await + } } diff --git a/libertas_api/src/services/user_service.rs b/libertas_api/src/services/user_service.rs index a30118f..5523856 100644 --- a/libertas_api/src/services/user_service.rs +++ b/libertas_api/src/services/user_service.rs @@ -96,4 +96,8 @@ impl UserService for UserServiceImpl { .await? .ok_or(CoreError::NotFound("User".to_string(), user_id)) } + + async fn search_users(&self, query: &str) -> CoreResult> { + self.repo.search_users(query).await + } } diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index 7dfb14f..fe484a4 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -120,7 +120,8 @@ pub struct AlbumMedia { pub media_id: uuid::Uuid, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] pub enum AlbumPermission { View, Contribute, @@ -166,7 +167,8 @@ pub struct Tag { pub name: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] pub enum PersonPermission { View, CanUse, diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 913c596..3df48d7 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -36,6 +36,7 @@ pub trait UserRepository: Send + Sync { async fn find_by_username(&self, username: &str) -> CoreResult>; async fn find_by_id(&self, id: Uuid) -> CoreResult>; async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()>; + async fn search_users(&self, query: &str) -> CoreResult>; } #[async_trait] @@ -73,6 +74,13 @@ pub trait AlbumShareRepository: Send + Sync { media_id: Uuid, user_id: Uuid, ) -> CoreResult; + + async fn list_shares_for_album( + &self, + album_id: Uuid, + ) -> CoreResult>; + + async fn remove_share(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()>; } #[async_trait] @@ -131,6 +139,11 @@ pub trait PersonShareRepository: Send + Sync { &self, user_id: Uuid, ) -> CoreResult>; + + async fn list_shares_for_person( + &self, + person_id: Uuid, + ) -> CoreResult>; } #[async_trait] diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index bf58376..92633b4 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -5,8 +5,8 @@ use crate::{ authz::Permission, error::CoreResult, models::{ - Album, FaceRegion, Media, MediaBundle, Person, PersonPermission, PublicAlbumBundle, Tag, - User, + Album, AlbumPermission, FaceRegion, Media, MediaBundle, Person, PersonPermission, + PublicAlbumBundle, Tag, User, }, schema::{ AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData, @@ -35,6 +35,7 @@ pub trait UserService: Send + Sync { async fn register(&self, data: CreateUserData<'_>) -> CoreResult; async fn login(&self, data: LoginUserData<'_>) -> CoreResult; async fn get_user_details(&self, user_id: uuid::Uuid) -> CoreResult; + async fn search_users(&self, query: &str) -> CoreResult>; } #[async_trait] @@ -65,6 +66,18 @@ pub trait AlbumService: Send + Sync { media_ids: &[Uuid], user_id: Uuid, ) -> CoreResult<()>; + async fn get_album_shares( + &self, + album_id: Uuid, + user_id: Uuid, + ) -> CoreResult>; + + async fn unshare_album( + &self, + album_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()>; } #[async_trait] @@ -143,6 +156,12 @@ pub trait PersonService: Send + Sync { user_id: Uuid, options: ListMediaOptions, ) -> CoreResult>; + + async fn get_person_shares( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult>; } #[async_trait] diff --git a/libertas_infra/src/db_models.rs b/libertas_infra/src/db_models.rs index 8df2861..3d14b4d 100644 --- a/libertas_infra/src/db_models.rs +++ b/libertas_infra/src/db_models.rs @@ -65,7 +65,7 @@ pub struct PostgresMediaMetadata { } #[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)] -#[sqlx(rename_all = "lowercase")] +#[sqlx(rename_all = "snake_case")] #[sqlx(type_name = "album_permission")] pub enum PostgresAlbumPermission { View, @@ -103,7 +103,7 @@ pub struct PostgresFaceRegion { } #[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)] -#[sqlx(rename_all = "lowercase")] +#[sqlx(rename_all = "snake_case")] #[sqlx(type_name = "person_permission")] pub enum PostgresPersonPermission { View, diff --git a/libertas_infra/src/repositories/album_share_repository.rs b/libertas_infra/src/repositories/album_share_repository.rs index 0b68cfb..4158a1f 100644 --- a/libertas_infra/src/repositories/album_share_repository.rs +++ b/libertas_infra/src/repositories/album_share_repository.rs @@ -111,4 +111,61 @@ impl AlbumShareRepository for PostgresAlbumShareRepository { Ok(result.exists.unwrap_or(false)) } + + async fn list_shares_for_album( + &self, + album_id: Uuid, + ) -> CoreResult> { + let rows = sqlx::query!( + r#" + SELECT + u.id, u.username, u.email, u.hashed_password, u.created_at, u.updated_at, + u.role, u.storage_quota, u.storage_used, + ash.permission as "permission: PostgresAlbumPermission" + FROM album_shares ash + JOIN users u ON ash.user_id = u.id + WHERE ash.album_id = $1 + "#, + album_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + let result = rows + .into_iter() + .map(|row| { + let user = crate::db_models::PostgresUser { + id: row.id, + username: row.username, + email: row.email, + hashed_password: row.hashed_password, + created_at: row.created_at, + updated_at: row.updated_at, + role: row.role, + storage_quota: row.storage_quota, + storage_used: row.storage_used, + } + .into(); + (user, row.permission.into()) + }) + .collect(); + + Ok(result) + } + + async fn remove_share(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + DELETE FROM album_shares + WHERE album_id = $1 AND user_id = $2 + "#, + album_id, + user_id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } } diff --git a/libertas_infra/src/repositories/person_share_repository.rs b/libertas_infra/src/repositories/person_share_repository.rs index 2851b14..25f3f74 100644 --- a/libertas_infra/src/repositories/person_share_repository.rs +++ b/libertas_infra/src/repositories/person_share_repository.rs @@ -4,7 +4,7 @@ use libertas_core::{ models::{Person, PersonPermission}, repositories::PersonShareRepository, }; -use sqlx::{types::Uuid, PgPool}; +use sqlx::{PgPool, types::Uuid}; use crate::db_models::{PostgresPersonPermission, PostgresPersonShared}; @@ -103,4 +103,46 @@ impl PersonShareRepository for PostgresPersonShareRepository { .map(PostgresPersonShared::into) .collect()) } -} \ No newline at end of file + + async fn list_shares_for_person( + &self, + person_id: Uuid, + ) -> CoreResult> { + let rows = sqlx::query!( + r#" + SELECT + u.id, u.username, u.email, u.hashed_password, u.created_at, u.updated_at, + u.role, u.storage_quota, u.storage_used, + ps.permission as "permission: PostgresPersonPermission" + FROM person_shares ps + JOIN users u ON ps.user_id = u.id + WHERE ps.person_id = $1 + "#, + person_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + let result = rows + .into_iter() + .map(|row| { + let user = crate::db_models::PostgresUser { + id: row.id, + username: row.username, + email: row.email, + hashed_password: row.hashed_password, + created_at: row.created_at, + updated_at: row.updated_at, + role: row.role, + storage_quota: row.storage_quota, + storage_used: row.storage_used, + } + .into(); + (user, row.permission.into()) + }) + .collect(); + + Ok(result) + } +} diff --git a/libertas_infra/src/repositories/user_repository.rs b/libertas_infra/src/repositories/user_repository.rs index 6d734be..8c4c060 100644 --- a/libertas_infra/src/repositories/user_repository.rs +++ b/libertas_infra/src/repositories/user_repository.rs @@ -137,6 +137,27 @@ impl UserRepository for PostgresUserRepository { async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()> { Self::update_storage_used_internal(&self.pool, user_id, bytes).await } + + async fn search_users(&self, query: &str) -> CoreResult> { + let pattern = format!("%{}%", query); + let pg_users = sqlx::query_as!( + PostgresUser, + r#" + SELECT + id, username, email, hashed_password, created_at, updated_at, + role, storage_quota, storage_used + FROM users + WHERE username ILIKE $1 OR email ILIKE $1 + LIMIT 10 + "#, + pattern + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(pg_users.into_iter().map(|u| u.into()).collect()) + } } #[async_trait] @@ -165,4 +186,9 @@ impl UserRepository for SqliteUserRepository { println!("SQLITE REPO: Updating user storage used"); Ok(()) } + + async fn search_users(&self, _query: &str) -> CoreResult> { + println!("SQLITE REPO: Searching users"); + Ok(Vec::new()) + } }