feat: Implement album and person sharing with user search and a dedicated share dialog.
This commit is contained in:
188
libertas-frontend/src/components/sharing/share-dialog.tsx
Normal file
188
libertas-frontend/src/components/sharing/share-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -132,9 +159,3 @@ export const useSetPersonThumbnail = (personId: string) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useClusterFaces = () => {
|
||||
return useMutation({
|
||||
mutationFn: clusterFaces,
|
||||
});
|
||||
};
|
||||
15
libertas-frontend/src/hooks/use-debounce.ts
Normal file
15
libertas-frontend/src/hooks/use-debounce.ts
Normal 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;
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -8,3 +8,13 @@ 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;
|
||||
};
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶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<AppState> {
|
||||
Router::new()
|
||||
.route("/me", axum::routing::get(get_me))
|
||||
.route("/search", axum::routing::get(search_users))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user