feat: Implement album and person sharing with user search and a dedicated share dialog.

This commit is contained in:
2025-12-04 00:58:10 +01:00
parent 74d74a128b
commit 7f07169064
23 changed files with 816 additions and 66 deletions

View File

@@ -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<void>;
onRemoveShare: (userId: string) => Promise<void>;
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<SharePermission>(
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex gap-2">
<Input
placeholder="Search users by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
<Select
value={selectedPermission}
onValueChange={(val) => setSelectedPermission(val as SharePermission)}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{permissionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Search Results */}
{searchQuery.length > 1 && (
<div className="border rounded-md p-2 max-h-[150px] overflow-y-auto space-y-2">
{isLoading ? (
<div className="flex justify-center p-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults && searchResults.length > 0 ? (
searchResults.map((user) => {
const isAlreadyShared = currentShares.some(
(s) => s.user.id === user.id
);
return (
<div
key={user.id}
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-md"
>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback>
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.username}
</span>
<span className="text-xs text-gray-500">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant="ghost"
disabled={isAlreadyShared}
onClick={() => handleAdd(user)}
>
{isAlreadyShared ? "Added" : "Add"}
</Button>
</div>
);
})
) : (
<p className="text-sm text-gray-500 text-center p-2">
No users found.
</p>
)}
</div>
)}
<div className="space-y-2">
<h4 className="text-sm font-medium">Shared with</h4>
{currentShares.length === 0 ? (
<p className="text-sm text-gray-500">Not shared with anyone yet.</p>
) : (
<div className="space-y-2">
{currentShares.map((share) => (
<div
key={share.user.id}
className="flex items-center justify-between border p-2 rounded-md"
>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback>
{share.user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">
{share.user.username}
</p>
<p className="text-xs text-gray-500">
{share.permission}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => onRemoveShare(share.user.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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.
*/

View File

@@ -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,
});
};

View File

@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -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<Media | null>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold truncate">
{album?.name ?? "Loading album..."}
</h1>
<AddMediaToAlbumDialog albumId={albumId} />
<div className="flex gap-2">
<ShareDialog
title={`Share Album: ${album?.name}`}
trigger={
<Button variant="outline">
<Share2 className="mr-2 h-4 w-4" />
Share
</Button>
}
currentShares={currentShares}
onAddShare={handleAddShare}
onRemoveShare={handleRemoveShare}
permissionOptions={[
{ value: "view", label: "Can View" },
{ value: "can_use", label: "Can Contribute" },
]}
/>
<AddMediaToAlbumDialog albumId={albumId} />
</div>
</div>
{isLoading && <p>Loading photos...</p>}

View File

@@ -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<Media | null>(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 <div>Loading person...</div>;
if (!person) return <div>Person not found</div>;
const allMedia = mediaPage?.pages.flatMap((page) => page.data) ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-3xl font-bold truncate">
{person?.name ?? "Loading person..."}
</h1>
{person && <EditPersonDialog person={person} />}
<div className="flex items-center justify-between">
{isEditingName ? (
<div className="flex items-center gap-2">
<input
className="text-3xl font-bold border rounded px-2 py-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
<Button onClick={handleNameSave}>Save</Button>
<Button variant="ghost" onClick={() => setIsEditingName(false)}>
Cancel
</Button>
</div>
) : (
<h1
className="text-3xl font-bold cursor-pointer hover:underline decoration-dashed"
onClick={() => {
setNewName(person.name);
setIsEditingName(true);
}}
title="Click to edit name"
>
{person.name}
</h1>
)}
<ShareDialog
title={`Share Person: ${person.name}`}
trigger={
<Button variant="outline">
<Share2 className="mr-2 h-4 w-4" />
Share
</Button>
}
currentShares={currentShares}
onAddShare={handleAddShare}
onRemoveShare={handleRemoveShare}
permissionOptions={[
{ value: "view", label: "Can View" },
{ value: "can_use", label: "Can Use (Assign Faces)" },
]}
/>
</div>
{(isLoadingPerson || isLoadingMedia) && !mediaPages && (
{(isLoadingPerson || isLoadingMedia) && !mediaPage && (
<p>Loading photos...</p>
)}

View File

@@ -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<void> => {
await apiClient.delete(`/albums/${albumId}/share`, {
data: { target_user_id: targetUserId },
});
};
export const setAlbumThumbnail = async (
albumId: string,
payload: SetAlbumThumbnailPayload,
): Promise<void> => {
await apiClient.put(`/albums/${albumId}/thumbnail`, payload);
};
export type AlbumShare = {
user: User;
permission: AlbumPermission;
};
export const getAlbumShares = async (albumId: string): Promise<AlbumShare[]> => {
const { data } = await apiClient.get(`/albums/${albumId}/share`);
return data;
};

View File

@@ -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<void> => {
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<PersonShare[]> => {
const { data } = await apiClient.get(`/people/${personId}/share`);
return data;
};
export const clusterFaces = async (): Promise<void> => {
await apiClient.post("/people/cluster");
};

View File

@@ -7,4 +7,14 @@ import apiClient from "@/services/api-client";
export const getMe = async (): Promise<User> => {
const { data } = await apiClient.get("/users/me");
return data;
};
/**
* Searches for users by username or email.
*/
export const searchUsers = async (query: string): Promise<User[]> => {
const { data } = await apiClient.get("/users/search", {
params: { query },
});
return data;
};

View File

@@ -83,6 +83,46 @@ async fn list_user_albums(
Ok(Json(response))
}
async fn unshare_album(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
Json(payload): Json<crate::schema::UnshareAlbumRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
) -> Result<Json<Vec<crate::schema::AlbumShareResponse>>, 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<AppState>,
UserId(user_id): UserId,
@@ -179,5 +219,10 @@ pub fn album_routes() -> Router<AppState> {
.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),
)
}

View File

@@ -29,7 +29,9 @@ pub fn people_routes() -> Router<AppState> {
)
.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<AppState>,
UserId(user_id): UserId,
Path(person_id): Path<Uuid>,
Json(payload): Json<SharePersonRequest>,
Json(payload): Json<crate::schema::UnsharePersonRequest>,
) -> Result<StatusCode, ApiError> {
state
.person_service
@@ -129,6 +131,33 @@ async fn unshare_person(
Ok(StatusCode::NO_CONTENT)
}
async fn list_person_shares(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(person_id): Path<Uuid>,
) -> Result<Json<Vec<crate::schema::PersonShareResponse>>, 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<AppState>,
UserId(user_id): UserId,

View File

@@ -18,6 +18,33 @@ pub async fn get_me(
Ok(Json(response))
}
pub fn user_routes() -> Router<AppState> {
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<AppState>,
axum::extract::Query(params): axum::extract::Query<SearchUserQuery>,
) -> Result<Json<Vec<UserResponse>>, ApiError> {
let users = state.user_service.search_users(&params.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<AppState> {
Router::new()
.route("/me", axum::routing::get(get_me))
.route("/search", axum::routing::get(search_users))
}

View File

@@ -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<MediaResponse>,
}
#[derive(Serialize)]
pub struct PersonShareResponse {
pub user: UserResponse,
pub permission: PersonPermission,
}
#[derive(Deserialize)]
pub struct MergePersonRequest {
pub source_person_id: Uuid,

View File

@@ -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
}
}

View File

@@ -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<Vec<(libertas_core::models::User, PersonPermission)>> {
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
}
}

View File

@@ -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<Vec<User>> {
self.repo.search_users(query).await
}
}

View File

@@ -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,

View File

@@ -36,6 +36,7 @@ pub trait UserRepository: Send + Sync {
async fn find_by_username(&self, username: &str) -> CoreResult<Option<User>>;
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<User>>;
async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()>;
async fn search_users(&self, query: &str) -> CoreResult<Vec<User>>;
}
#[async_trait]
@@ -73,6 +74,13 @@ pub trait AlbumShareRepository: Send + Sync {
media_id: Uuid,
user_id: Uuid,
) -> CoreResult<bool>;
async fn list_shares_for_album(
&self,
album_id: Uuid,
) -> CoreResult<Vec<(User, AlbumPermission)>>;
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<Vec<(Person, PersonPermission)>>;
async fn list_shares_for_person(
&self,
person_id: Uuid,
) -> CoreResult<Vec<(User, PersonPermission)>>;
}
#[async_trait]

View File

@@ -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<User>;
async fn login(&self, data: LoginUserData<'_>) -> CoreResult<String>;
async fn get_user_details(&self, user_id: uuid::Uuid) -> CoreResult<User>;
async fn search_users(&self, query: &str) -> CoreResult<Vec<User>>;
}
#[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<Vec<(User, AlbumPermission)>>;
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<PaginatedResponse<Media>>;
async fn get_person_shares(
&self,
person_id: Uuid,
user_id: Uuid,
) -> CoreResult<Vec<(User, PersonPermission)>>;
}
#[async_trait]

View File

@@ -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,

View File

@@ -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<Vec<(libertas_core::models::User, AlbumPermission)>> {
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(())
}
}

View File

@@ -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())
}
}
async fn list_shares_for_person(
&self,
person_id: Uuid,
) -> CoreResult<Vec<(libertas_core::models::User, PersonPermission)>> {
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)
}
}

View File

@@ -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<Vec<User>> {
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<Vec<User>> {
println!("SQLITE REPO: Searching users");
Ok(Vec::new())
}
}