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); } } 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) => { public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const { organizationIds } = req.body; const { organizationIds } = req.body;

View File

@ -10,6 +10,7 @@ router.use(authenticate);
router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset); router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
router.get('/', assetController.listAssets); router.get('/', assetController.listAssets);
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
router.get('/:id', assetController.getAsset); router.get('/:id', assetController.getAsset);
router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset); router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset);
router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset); router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset);

View File

@ -267,6 +267,24 @@ export class AssetService {
return this.getAssetById(assetId); 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) { public async incrementDownloadCount(id: string) {
return await prisma.asset.update({ return await prisma.asset.update({
where: { id }, where: { id },

View File

@ -30,6 +30,8 @@ interface AssetCardProps {
onOpenViewer: (asset: Asset) => void; onOpenViewer: (asset: Asset) => void;
onDownload: (asset: Asset) => void; onDownload: (asset: Asset) => void;
onRequestDownload: (asset: Asset) => void; onRequestDownload: (asset: Asset) => void;
isSelected?: boolean;
onToggleSelect?: (assetId: string) => void;
} }
export const AssetCard: React.FC<AssetCardProps> = ({ export const AssetCard: React.FC<AssetCardProps> = ({
@ -43,7 +45,9 @@ export const AssetCard: React.FC<AssetCardProps> = ({
onDelete, onDelete,
onOpenViewer, onOpenViewer,
onDownload, onDownload,
onRequestDownload onRequestDownload,
isSelected = false,
onToggleSelect
}) => { }) => {
const isMenuOpen = activeMenuId === asset.id; const isMenuOpen = activeMenuId === asset.id;
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED'; const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
@ -72,12 +76,22 @@ export const AssetCard: React.FC<AssetCardProps> = ({
const Icon = getAssetIcon(asset.type); const Icon = getAssetIcon(asset.type);
return ( 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>
<div className="flex justify-between items-start mb-3 relative"> <div className="flex justify-between items-start mb-3 relative">
<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"> <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" /> <Icon className="w-5 h-5" />
</div> </div>
</div>
<div className="relative flex items-center gap-1.5"> <div className="relative flex items-center gap-1.5">
{isRenderable(asset.type) && ( {isRenderable(asset.type) && (

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; 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 type { Asset, Organization, ShareItem } from '../../../types/assets';
import Modal from '../../../components/ui/Modal'; import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button'; import Button from '../../../components/ui/Button';
@ -11,6 +11,7 @@ interface ShareAssetModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
asset: Asset | null; asset: Asset | null;
assetIds?: string[] | null;
organizations: Organization[]; organizations: Organization[];
onSuccess: () => void; onSuccess: () => void;
} }
@ -19,6 +20,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
isOpen, isOpen,
onClose, onClose,
asset, asset,
assetIds,
organizations, organizations,
onSuccess onSuccess
}) => { }) => {
@ -35,8 +37,10 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
userId: s.userId userId: s.userId
})) || [] })) || []
); );
} else {
setSharesList([]);
} }
}, [asset]); }, [asset, isOpen]);
const isOrgSharedEntirely = (orgId: string) => { const isOrgSharedEntirely = (orgId: string) => {
return sharesList.some(s => s.organizationId === orgId && s.userId === null); 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) => { const handleShareSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!asset) return; if (!asset && (!assetIds || assetIds.length === 0)) return;
setIsSavingShare(true); setIsSavingShare(true);
try { try {
if (asset) {
await updateAsset(asset.id, { await updateAsset(asset.id, {
shares: sharesList shares: sharesList
}); });
} else if (assetIds && assetIds.length > 0) {
await bulkShareAssets(assetIds, sharesList);
}
success('Share permissions updated', 'The asset visibility settings have been updated.'); success('Share permissions updated', 'The asset visibility settings have been updated.');
onSuccess(); onSuccess();
onClose(); onClose();
@ -92,10 +100,10 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
return ( return (
<Modal <Modal
isOpen={isOpen && !!asset} isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))}
onClose={onClose} onClose={onClose}
title="Share Settings" title="Share Settings"
subtitle={asset?.title} subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`}
size="md" size="md"
footer={ 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"> <form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4">
<p className="text-xs text-ink-600 leading-relaxed font-sans"> <p className="text-xs text-ink-600 leading-relaxed font-sans">
Select organizations or expand to specify exact users that can access this asset: 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 { useState, useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { Variants } 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 { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios"; import { axiosInstance } from "../services/axios";
import { import {
@ -66,6 +66,7 @@ export const AssetsPage = () => {
const [activeAsset, setActiveAsset] = useState<Asset | null>(null); const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null); const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@ -76,6 +77,7 @@ export const AssetsPage = () => {
try { try {
const assetsData = await getAssets(); const assetsData = await getAssets();
setAssets(assetsData); setAssets(assetsData);
setSelectedAssetIds([]);
if (user?.role === "ADMIN") { if (user?.role === "ADMIN") {
const orgsData = await getOrganizations(); const orgsData = await getOrganizations();
@ -94,10 +96,19 @@ export const AssetsPage = () => {
}; };
const openShareModal = (asset: Asset) => { const openShareModal = (asset: Asset) => {
setSelectedAssetIds([]);
setActiveAsset(asset); setActiveAsset(asset);
setIsShareOpen(true); setIsShareOpen(true);
}; };
const handleToggleSelectAsset = (assetId: string) => {
setSelectedAssetIds((prev) =>
prev.includes(assetId)
? prev.filter((id) => id !== assetId)
: [...prev, assetId]
);
};
const openViewerModal = (asset: Asset) => { const openViewerModal = (asset: Asset) => {
setActiveAsset(asset); setActiveAsset(asset);
setIsViewerOpen(true); setIsViewerOpen(true);
@ -288,6 +299,20 @@ export const AssetsPage = () => {
</Button> </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" && ( {user?.role === "ADMIN" && (
<Button <Button
onClick={() => setIsUploadOpen(true)} onClick={() => setIsUploadOpen(true)}
@ -338,6 +363,8 @@ export const AssetsPage = () => {
onOpenViewer={openViewerModal} onOpenViewer={openViewerModal}
onDownload={handleDownload} onDownload={handleDownload}
onRequestDownload={handleRequestDownload} onRequestDownload={handleRequestDownload}
isSelected={selectedAssetIds.includes(asset.id)}
onToggleSelect={handleToggleSelectAsset}
/> />
</motion.div> </motion.div>
))} ))}
@ -367,8 +394,10 @@ export const AssetsPage = () => {
onClose={() => { onClose={() => {
setIsShareOpen(false); setIsShareOpen(false);
setActiveAsset(null); setActiveAsset(null);
setSelectedAssetIds([]);
}} }}
asset={activeAsset} asset={activeAsset}
assetIds={selectedAssetIds}
organizations={organizations} organizations={organizations}
onSuccess={fetchData} onSuccess={fetchData}
/> />

View File

@ -52,3 +52,10 @@ export const rejectDownloadRequest = async (assetId: string, requestId: string):
export const downloadAssetFile = async (id: string): Promise<void> => { export const downloadAssetFile = async (id: string): Promise<void> => {
await axiosInstance.post(`/assets/${id}/download`); 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 });
};