implement_bulk_asset_sharing

This commit is contained in:
kenilkb 2026-07-10 18:13:08 +05:30
parent b7efb22ed1
commit 9281a9d384
7 changed files with 105 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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