implement_bulk_asset_sharing
This commit is contained in:
parent
b7efb22ed1
commit
9281a9d384
@ -88,6 +88,20 @@ export class AssetController {
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public bulkShareAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { assetIds, shares } = req.body;
|
||||
if (!Array.isArray(assetIds)) {
|
||||
return res.status(400).json({ error: 'assetIds must be an array' });
|
||||
}
|
||||
if (!Array.isArray(shares)) {
|
||||
return res.status(400).json({ error: 'shares must be an array' });
|
||||
}
|
||||
await this.assetService.bulkShareAssets(assetIds, shares);
|
||||
res.status(200).json({ message: 'Assets shared successfully' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { organizationIds } = req.body;
|
||||
|
||||
@ -10,6 +10,7 @@ router.use(authenticate);
|
||||
|
||||
router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
|
||||
router.get('/', assetController.listAssets);
|
||||
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
|
||||
router.get('/:id', assetController.getAsset);
|
||||
router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset);
|
||||
router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset);
|
||||
|
||||
@ -267,6 +267,24 @@ export class AssetService {
|
||||
return this.getAssetById(assetId);
|
||||
}
|
||||
|
||||
public async bulkShareAssets(assetIds: string[], shares: any[]) {
|
||||
await prisma.$transaction([
|
||||
prisma.sharedAsset.deleteMany({
|
||||
where: { assetId: { in: assetIds } }
|
||||
}),
|
||||
prisma.sharedAsset.createMany({
|
||||
data: assetIds.flatMap(assetId =>
|
||||
shares.map(s => ({
|
||||
assetId,
|
||||
organizationId: s.organizationId,
|
||||
userId: s.userId || null,
|
||||
}))
|
||||
),
|
||||
skipDuplicates: true
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
public async incrementDownloadCount(id: string) {
|
||||
return await prisma.asset.update({
|
||||
where: { id },
|
||||
|
||||
@ -30,6 +30,8 @@ interface AssetCardProps {
|
||||
onOpenViewer: (asset: Asset) => void;
|
||||
onDownload: (asset: Asset) => void;
|
||||
onRequestDownload: (asset: Asset) => void;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: (assetId: string) => void;
|
||||
}
|
||||
|
||||
export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
@ -43,7 +45,9 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
onDelete,
|
||||
onOpenViewer,
|
||||
onDownload,
|
||||
onRequestDownload
|
||||
onRequestDownload,
|
||||
isSelected = false,
|
||||
onToggleSelect
|
||||
}) => {
|
||||
const isMenuOpen = activeMenuId === asset.id;
|
||||
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
|
||||
@ -72,11 +76,21 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
const Icon = getAssetIcon(asset.type);
|
||||
|
||||
return (
|
||||
<div className="group relative bg-ink-0 border border-ink-200 rounded-xl p-4 hover:border-ink-300 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
|
||||
<div className={`group relative bg-ink-0 border rounded-xl p-4 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between ${isSelected ? 'border-ink-900 ring-1 ring-ink-900 bg-ink-50/30' : 'border-ink-200 hover:border-ink-300'}`}>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3 relative">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200">
|
||||
<Icon className="w-5 h-5" />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.role === 'ADMIN' && onToggleSelect && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(asset.id)}
|
||||
className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer mr-1"
|
||||
/>
|
||||
)}
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { updateAsset } from '../../../services/assets-api';
|
||||
import { updateAsset, bulkShareAssets } from '../../../services/assets-api';
|
||||
import type { Asset, Organization, ShareItem } from '../../../types/assets';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
import Button from '../../../components/ui/Button';
|
||||
@ -11,6 +11,7 @@ interface ShareAssetModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
asset: Asset | null;
|
||||
assetIds?: string[] | null;
|
||||
organizations: Organization[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
@ -19,6 +20,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
asset,
|
||||
assetIds,
|
||||
organizations,
|
||||
onSuccess
|
||||
}) => {
|
||||
@ -35,8 +37,10 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
userId: s.userId
|
||||
})) || []
|
||||
);
|
||||
} else {
|
||||
setSharesList([]);
|
||||
}
|
||||
}, [asset]);
|
||||
}, [asset, isOpen]);
|
||||
|
||||
const isOrgSharedEntirely = (orgId: string) => {
|
||||
return sharesList.some(s => s.organizationId === orgId && s.userId === null);
|
||||
@ -72,13 +76,17 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
|
||||
const handleShareSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!asset) return;
|
||||
if (!asset && (!assetIds || assetIds.length === 0)) return;
|
||||
|
||||
setIsSavingShare(true);
|
||||
try {
|
||||
await updateAsset(asset.id, {
|
||||
shares: sharesList
|
||||
});
|
||||
if (asset) {
|
||||
await updateAsset(asset.id, {
|
||||
shares: sharesList
|
||||
});
|
||||
} else if (assetIds && assetIds.length > 0) {
|
||||
await bulkShareAssets(assetIds, sharesList);
|
||||
}
|
||||
success('Share permissions updated', 'The asset visibility settings have been updated.');
|
||||
onSuccess();
|
||||
onClose();
|
||||
@ -92,10 +100,10 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen && !!asset}
|
||||
isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))}
|
||||
onClose={onClose}
|
||||
title="Share Settings"
|
||||
subtitle={asset?.title}
|
||||
subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
@ -119,7 +127,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
{asset && (
|
||||
{(asset || (assetIds && assetIds.length > 0)) && (
|
||||
<form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4">
|
||||
<p className="text-xs text-ink-600 leading-relaxed font-sans">
|
||||
Select organizations or expand to specify exact users that can access this asset:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { Variants } from "framer-motion";
|
||||
import { UploadCloud, Search, File, CheckCircle } from "lucide-react";
|
||||
import { UploadCloud, Search, File, CheckCircle, Share2 } from "lucide-react";
|
||||
import { useAuthStore } from "../hooks/use-auth";
|
||||
import { axiosInstance } from "../services/axios";
|
||||
import {
|
||||
@ -66,6 +66,7 @@ export const AssetsPage = () => {
|
||||
|
||||
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
|
||||
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@ -76,6 +77,7 @@ export const AssetsPage = () => {
|
||||
try {
|
||||
const assetsData = await getAssets();
|
||||
setAssets(assetsData);
|
||||
setSelectedAssetIds([]);
|
||||
|
||||
if (user?.role === "ADMIN") {
|
||||
const orgsData = await getOrganizations();
|
||||
@ -94,10 +96,19 @@ export const AssetsPage = () => {
|
||||
};
|
||||
|
||||
const openShareModal = (asset: Asset) => {
|
||||
setSelectedAssetIds([]);
|
||||
setActiveAsset(asset);
|
||||
setIsShareOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleSelectAsset = (assetId: string) => {
|
||||
setSelectedAssetIds((prev) =>
|
||||
prev.includes(assetId)
|
||||
? prev.filter((id) => id !== assetId)
|
||||
: [...prev, assetId]
|
||||
);
|
||||
};
|
||||
|
||||
const openViewerModal = (asset: Asset) => {
|
||||
setActiveAsset(asset);
|
||||
setIsViewerOpen(true);
|
||||
@ -288,6 +299,20 @@ export const AssetsPage = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user?.role === "ADMIN" && selectedAssetIds.length > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveAsset(null);
|
||||
setIsShareOpen(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Share2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
Share Selected ({selectedAssetIds.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user?.role === "ADMIN" && (
|
||||
<Button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
@ -338,6 +363,8 @@ export const AssetsPage = () => {
|
||||
onOpenViewer={openViewerModal}
|
||||
onDownload={handleDownload}
|
||||
onRequestDownload={handleRequestDownload}
|
||||
isSelected={selectedAssetIds.includes(asset.id)}
|
||||
onToggleSelect={handleToggleSelectAsset}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@ -367,8 +394,10 @@ export const AssetsPage = () => {
|
||||
onClose={() => {
|
||||
setIsShareOpen(false);
|
||||
setActiveAsset(null);
|
||||
setSelectedAssetIds([]);
|
||||
}}
|
||||
asset={activeAsset}
|
||||
assetIds={selectedAssetIds}
|
||||
organizations={organizations}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
@ -52,3 +52,10 @@ export const rejectDownloadRequest = async (assetId: string, requestId: string):
|
||||
export const downloadAssetFile = async (id: string): Promise<void> => {
|
||||
await axiosInstance.post(`/assets/${id}/download`);
|
||||
};
|
||||
|
||||
export const bulkShareAssets = async (
|
||||
assetIds: string[],
|
||||
shares: Array<{ organizationId: string; userId: string | null }>
|
||||
): Promise<void> => {
|
||||
await axiosInstance.patch('/assets/bulk-share', { assetIds, shares });
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user