From 61f537057b18a87739bbb6e7a3d4ec08ff1ae9c0 Mon Sep 17 00:00:00 2001 From: kenilkb Date: Tue, 21 Jul 2026 14:11:41 +0530 Subject: [PATCH] Asset Management & seach, filter funationalities --- Channel-Backend/prisma/schema.prisma | 26 + Channel-Backend/scripts/normalize-data.ts | 148 ++++++ Channel-Backend/src/app.ts | 4 + .../src/controllers/asset.controller.ts | 16 +- .../controllers/notification.controller.ts | 72 +++ .../src/controllers/taxonomy.controller.ts | 120 +++++ .../src/routes/notification.routes.ts | 11 + Channel-Backend/src/routes/taxonomy.routes.ts | 15 + Channel-Backend/src/services/asset.service.ts | 184 +++++-- Channel-Backend/src/services/mail.service.ts | 22 + .../components/AssetAdminManagerModal.tsx | 464 ++++++++++++++++++ .../features/assets/components/AssetCard.tsx | 42 +- .../assets/components/AssetTableView.tsx | 189 +++++++ .../assets/components/AssetViewerModal.tsx | 10 +- .../assets/components/DocxThumbnail.tsx | 2 +- .../assets/components/EditAssetModal.tsx | 27 +- .../assets/components/FilterDrawer.tsx | 328 +++++++++++++ .../assets/components/UploadAssetModal.tsx | 28 +- Channel-Frontend/src/pages/AssetsPage.tsx | 453 ++++++++++++----- Channel-Frontend/src/services/assets-api.ts | 46 +- Channel-Frontend/src/types/assets.ts | 34 ++ minio-seed/1783584634114-350366184.png | Bin 289784 -> 0 bytes minio-seed/1783584835913-404486304.zip | Bin 177286 -> 0 bytes minio-seed/1783587276072-982233853.pdf | Bin 27308 -> 0 bytes 24 files changed, 2025 insertions(+), 216 deletions(-) create mode 100644 Channel-Backend/scripts/normalize-data.ts create mode 100644 Channel-Backend/src/controllers/notification.controller.ts create mode 100644 Channel-Backend/src/controllers/taxonomy.controller.ts create mode 100644 Channel-Backend/src/routes/notification.routes.ts create mode 100644 Channel-Backend/src/routes/taxonomy.routes.ts create mode 100644 Channel-Frontend/src/features/assets/components/AssetAdminManagerModal.tsx create mode 100644 Channel-Frontend/src/features/assets/components/AssetTableView.tsx create mode 100644 Channel-Frontend/src/features/assets/components/FilterDrawer.tsx delete mode 100644 minio-seed/1783584634114-350366184.png delete mode 100644 minio-seed/1783584835913-404486304.zip delete mode 100644 minio-seed/1783587276072-982233853.pdf diff --git a/Channel-Backend/prisma/schema.prisma b/Channel-Backend/prisma/schema.prisma index 936e412..a842189 100644 --- a/Channel-Backend/prisma/schema.prisma +++ b/Channel-Backend/prisma/schema.prisma @@ -76,8 +76,10 @@ model Asset { thumbnailUrl String? problemStatement String? @db.Text solution String? @db.Text + contentType String? folderId String? folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + verticals Vertical[] @relation("AssetVerticals") sharedWith SharedAsset[] downloadRequests DownloadRequest[] assetGroups AssetGroup[] @@ -85,6 +87,30 @@ model Asset { updatedAt DateTime @updatedAt } +model Vertical { + id String @id @default(uuid()) + name String @unique + slug String @unique + icon String? + description String? + color String? + orderIndex Int @default(0) + isActive Boolean @default(true) + assets Asset[] @relation("AssetVerticals") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model AssetNotification { + id String @id @default(uuid()) + title String + message String @db.Text + sentBy String + targetOrgIds String[] + assetIds String[] + createdAt DateTime @default(now()) +} + model SharedAsset { id String @id @default(uuid()) assetId String diff --git a/Channel-Backend/scripts/normalize-data.ts b/Channel-Backend/scripts/normalize-data.ts new file mode 100644 index 0000000..e4650a8 --- /dev/null +++ b/Channel-Backend/scripts/normalize-data.ts @@ -0,0 +1,148 @@ +import dotenv from 'dotenv'; +dotenv.config(); +import prisma from '../src/utils/db'; + +const INITIAL_VERTICALS = [ + { name: 'Cybersecurity', slug: 'cybersecurity', icon: 'Shield', color: '#ef4444', description: 'OT, Infrastructure & Data Defense' }, + { name: 'AI & ML', slug: 'ai-ml', icon: 'Cpu', color: '#8b5cf6', description: 'Artificial Intelligence & Machine Learning' }, + { name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Activity', color: '#ec4899', description: 'Medical, Diagnostics & BioTech' }, + { name: 'Finance & Banking', slug: 'finance-banking', icon: 'Landmark', color: '#10b981', description: 'FinTech, Payments & Risk Analytics' }, + { name: 'Insurance', slug: 'insurance', icon: 'FileCheck', color: '#3b82f6', description: 'InsurTech, Claims & Underwriting' }, + { name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', color: '#f59e0b', description: 'Smart Grid, Power & Renewables' }, + { name: 'Agriculture', slug: 'agriculture', icon: 'Leaf', color: '#84cc16', description: 'AgriTech, Precision Farming & Supply' }, + { name: 'Education', slug: 'education', icon: 'GraduationCap', color: '#06b6d4', description: 'EdTech, LMS & Institutional Tools' }, + { name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Factory', color: '#6366f1', description: 'Industrial Automation & Edge IoT' }, + { name: 'Automotive', slug: 'automotive', icon: 'Car', color: '#14b8a6', description: 'Connected Vehicles & Telematics' }, + { name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', color: '#d97706', description: 'E-Commerce, Logistics & Smart Retail' }, + { name: 'Blockchain', slug: 'blockchain', icon: 'Link', color: '#0284c7', description: 'Smart Contracts & Web3 Infrastructure' }, +]; + +function inferContentType(type: string, subcategory?: string | null): string { + const sub = (subcategory || '').toLowerCase().trim(); + if (sub === 'case study') return 'case_study'; + if (sub === 'showcase') return 'showcase'; + if (sub === 'news letter' || sub === 'marketing milestone') return 'newsletter'; + if (sub === 'portfolio' || sub === 'company deck' || sub === 'product showcase' || sub === 'rnd innovation') return 'portfolio'; + if (sub === 'mvp') return 'mvp'; + if (sub === 'workflow automation' || sub === 'worlflow automation') return 'workflow'; + if (sub === 'use case') return 'use_case'; + if (sub === 'test drive resources') return 'test_drive'; + if (type === 'case_study') return 'case_study'; + if (type === 'url') return 'showcase'; + if (type.includes('pdf') || type.includes('document') || type.includes('word')) return 'document'; + if (type.includes('sheet') || type.includes('csv') || type.includes('excel')) return 'spreadsheet'; + if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation'; + if (type.includes('image')) return 'image'; + return 'document'; +} + +function matchVerticalSlugs(title: string, description?: string | null): string[] { + const text = `${title} ${description || ''}`.toLowerCase(); + const matched = new Set(); + + if (/cybersecurity|security|threat|ot |defence|defense|ransomware|audit|hacker|firewall|fpga|compliance|kyc|aml/.test(text)) { + matched.add('cybersecurity'); + } + if (/ai|machine learning|resnet|densenet|nlp|chatbot|genai|deep learning|prediction|predictive|forecasting|gpt|n8n|rag|speech/.test(text)) { + matched.add('ai-ml'); + } + if (/health|medical|pharma|drug|cancer|hospital|patient|doctor|eye|blood|hematology|x-ray|brain tumor|bio|biotech|wearable|ventilator|ct scan/.test(text)) { + matched.add('healthcare-pharma'); + } + if (/bank|fintech|payment|fraud|credit|loan|financial|accounting|cash|revenue|investor|audit|trade|b2b/.test(text)) { + matched.add('finance-banking'); + } + if (/insurance|claims|underwriting|insurtech|policy|catastrophe|actuary/.test(text)) { + matched.add('insurance'); + } + if (/energy|grid|power|renewable|ev |electric vehicle|utility|utilities|battery|charging|power plant|oms|ems|metering|solar|wind/.test(text)) { + matched.add('energy-utilities'); + } + if (/agri|farm|crop|livestock|soil|aqua|pest|aquaponics|harvest|yield/.test(text)) { + matched.add('agriculture'); + } + if (/education|student|learning|lms|classroom|exam|academic|textbook|proctoring|university|school/.test(text)) { + matched.add('education'); + } + if (/manufactur|industrial|iot|edge|predictive maintenance|digital twin|esp32|ble board|pcb|robotics|factory|plc/.test(text)) { + matched.add('manufacturing-iot'); + } + if (/vehicle|automotive|fleet|telematics|adas|car|driving|mobility|maas/.test(text)) { + matched.add('automotive'); + } + if (/retail|e-commerce|supply chain|inventory|mall|store|basket|procurement|logistics|fmcg|pos/.test(text)) { + matched.add('retail-supply-chain'); + } + if (/blockchain|smart contract|credentialing|traceability/.test(text)) { + matched.add('blockchain'); + } + + return Array.from(matched); +} + +async function main() { + console.log('🚀 Starting Data Normalization & Taxonomy Seeding...'); + + // 1. Seed Verticals + const verticalMap = new Map(); // slug -> id + for (const v of INITIAL_VERTICALS) { + const upserted = await prisma.vertical.upsert({ + where: { slug: v.slug }, + update: { name: v.name, icon: v.icon, color: v.color, description: v.description }, + create: v, + }); + verticalMap.set(v.slug, upserted.id); + } + console.log(`✅ ${verticalMap.size} Verticals ready.`); + + // 2. Fix typos in Subcategories + await prisma.asset.updateMany({ + where: { subcategory: { in: ['Showcaase', 'showcase'] } }, + data: { subcategory: 'Showcase' }, + }); + await prisma.asset.updateMany({ + where: { subcategory: 'Worlflow Automation' }, + data: { subcategory: 'Workflow Automation' }, + }); + await prisma.asset.updateMany({ + where: { subcategory: 'Use Case', categoryId: 'Marketing' }, + data: { categoryId: 'Technical' }, + }); + await prisma.asset.updateMany({ + where: { subcategory: 'MVP', categoryId: 'Presentations' }, + data: { categoryId: 'Resources' }, + }); + console.log('✅ Subcategory typos & category alignments fixed.'); + + // 3. Process all assets + const assets = await prisma.asset.findMany(); + let updatedCount = 0; + + for (const asset of assets) { + const contentType = inferContentType(asset.type, asset.subcategory); + const matchedSlugs = matchVerticalSlugs(asset.title, asset.description); + const verticalIds = matchedSlugs.map(slug => verticalMap.get(slug)).filter(Boolean) as string[]; + + await prisma.asset.update({ + where: { id: asset.id }, + data: { + contentType, + verticals: { + set: verticalIds.map(id => ({ id })), + }, + }, + }); + updatedCount++; + } + + console.log(`🎉 Successfully normalized ${updatedCount} assets with content types & vertical tags!`); +} + +main() + .catch(err => { + console.error('❌ Migration failed:', err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/Channel-Backend/src/app.ts b/Channel-Backend/src/app.ts index d58df79..973a86f 100644 --- a/Channel-Backend/src/app.ts +++ b/Channel-Backend/src/app.ts @@ -16,6 +16,8 @@ import assetRoutes from './routes/asset.routes'; import orgRoutes from './routes/organization.routes'; import legalRoutes from './routes/legal.routes'; import ecosystemRoutes from './routes/ecosystem.routes'; +import taxonomyRoutes from './routes/taxonomy.routes'; +import notificationRoutes from './routes/notification.routes'; import { ensureBucketExists } from './utils/s3'; import { originStorage } from './utils/origin-storage'; @@ -106,9 +108,11 @@ app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunc // API Routes app.use('/api/v1/auth', authRoutes); app.use('/api/v1/assets', assetRoutes); +app.use('/api/v1/assets', notificationRoutes); app.use('/api/v1/organizations', orgRoutes); app.use('/api/v1/legal', legalRoutes); app.use('/api/v1/ecosystem', ecosystemRoutes); +app.use('/api/v1/taxonomy', taxonomyRoutes); app.get('/api/v1/health', (req: Request, res: Response) => { res.status(200).json({ status: 'success', message: 'API is fully functional and real.' }); diff --git a/Channel-Backend/src/controllers/asset.controller.ts b/Channel-Backend/src/controllers/asset.controller.ts index 52c71ec..be0fc76 100644 --- a/Channel-Backend/src/controllers/asset.controller.ts +++ b/Channel-Backend/src/controllers/asset.controller.ts @@ -59,6 +59,11 @@ export class AssetController { fileUrl = req.body.url; } + let verticalIds = req.body.verticalIds; + if (typeof verticalIds === 'string' && verticalIds.trim()) { + try { verticalIds = JSON.parse(verticalIds); } catch { verticalIds = verticalIds.split(',').map((id: string) => id.trim()).filter(Boolean); } + } + const assetData = { title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'), type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype), @@ -75,6 +80,7 @@ export class AssetController { thumbnailUrl: req.body.thumbnailUrl || null, problemStatement: req.body.problemStatement || null, solution: req.body.solution || null, + verticalIds, shares, sharedOrgIds: req.body.sharedOrgIds || null, }; @@ -87,7 +93,15 @@ export class AssetController { public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => { try { const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined; - const assets = await this.assetService.getAssets(userContext); + const filters = { + search: req.query.search as string, + verticalIds: req.query.verticalIds ? (req.query.verticalIds as string).split(',') : undefined, + contentTypes: req.query.contentTypes ? (req.query.contentTypes as string).split(',') : undefined, + subcategories: req.query.subcategories ? (req.query.subcategories as string).split(',') : undefined, + tags: req.query.tags ? (req.query.tags as string).split(',') : undefined, + sortBy: req.query.sortBy as any, + }; + const assets = await this.assetService.getAssets(userContext, filters); res.status(200).json(assets); } catch(err) { next(err); } } diff --git a/Channel-Backend/src/controllers/notification.controller.ts b/Channel-Backend/src/controllers/notification.controller.ts new file mode 100644 index 0000000..980b898 --- /dev/null +++ b/Channel-Backend/src/controllers/notification.controller.ts @@ -0,0 +1,72 @@ +import { Response, NextFunction } from 'express'; +import prisma from '../utils/db'; +import { AuthRequest } from '../middleware/auth.middleware'; +import { MailService } from '../services/mail.service'; + +export class NotificationController { + private mailService = new MailService(); + + public sendAssetAnnouncement = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { title, message, targetOrgIds, assetIds } = req.body; + if (!title || !message) { + return res.status(400).json({ error: 'Title and message are required' }); + } + + let senderEmail = 'admin@tech4biz.com'; + if (req.user?.userId) { + const adminUser = await prisma.user.findUnique({ where: { id: req.user.userId }, select: { email: true } }); + if (adminUser?.email) senderEmail = adminUser.email; + } + + // Log notification record + const notification = await prisma.assetNotification.create({ + data: { + title, + message, + sentBy: senderEmail, + targetOrgIds: Array.isArray(targetOrgIds) ? targetOrgIds : ['ALL'], + assetIds: Array.isArray(assetIds) ? assetIds : [], + } + }); + + // Find recipient users + const userWhere: any = { role: 'PARTNER_USER' }; + if (Array.isArray(targetOrgIds) && targetOrgIds.length > 0 && !targetOrgIds.includes('ALL')) { + userWhere.organizationId = { in: targetOrgIds }; + } + + const partnerUsers = await prisma.user.findMany({ + where: userWhere, + select: { email: true } + }); + + const recipientEmails = partnerUsers.map(u => u.email).filter(Boolean); + + // Trigger email notifications asynchronously via mailService + if (recipientEmails.length > 0) { + this.mailService.sendCustomAnnouncement({ + recipients: recipientEmails, + subject: `📢 Asset Announcement: ${title}`, + messageBody: message, + }).catch(err => console.error('Failed to dispatch announcement emails:', err)); + } + + res.status(201).json({ + message: 'Notification sent successfully', + recipientsCount: recipientEmails.length, + notification, + }); + } catch (err) { next(err); } + }; + + public getNotificationLogs = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const logs = await prisma.assetNotification.findMany({ + orderBy: { createdAt: 'desc' }, + take: 50, + }); + res.status(200).json(logs); + } catch (err) { next(err); } + }; +} diff --git a/Channel-Backend/src/controllers/taxonomy.controller.ts b/Channel-Backend/src/controllers/taxonomy.controller.ts new file mode 100644 index 0000000..705ad81 --- /dev/null +++ b/Channel-Backend/src/controllers/taxonomy.controller.ts @@ -0,0 +1,120 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/db'; +import { AuthRequest } from '../middleware/auth.middleware'; + +export class TaxonomyController { + // Public/Authenticated: Get all active verticals + public getVerticals = async (req: Request, res: Response, next: NextFunction) => { + try { + const verticals = await prisma.vertical.findMany({ + where: { isActive: true }, + include: { + _count: { + select: { assets: true } + } + }, + orderBy: { orderIndex: 'asc' }, + }); + res.status(200).json(verticals); + } catch (err) { next(err); } + }; + + // Public/Authenticated: Get taxonomy metadata with real-time asset counts + public getTaxonomyMeta = async (req: Request, res: Response, next: NextFunction) => { + try { + const [verticals, assets] = await Promise.all([ + prisma.vertical.findMany({ + where: { isActive: true }, + include: { _count: { select: { assets: true } } }, + orderBy: { orderIndex: 'asc' }, + }), + prisma.asset.findMany({ + select: { + categoryId: true, + subcategory: true, + contentType: true, + tags: true, + } + }) + ]); + + const categoryCounts: Record = {}; + const subcategoryCounts: Record = {}; + const contentTypeCounts: Record = {}; + const tagCounts: Record = {}; + + assets.forEach(a => { + if (a.categoryId) { + categoryCounts[a.categoryId] = (categoryCounts[a.categoryId] || 0) + 1; + } + if (a.subcategory) { + subcategoryCounts[a.subcategory] = (subcategoryCounts[a.subcategory] || 0) + 1; + } + if (a.contentType) { + contentTypeCounts[a.contentType] = (contentTypeCounts[a.contentType] || 0) + 1; + } + if (Array.isArray(a.tags)) { + a.tags.forEach(t => { + if (t) tagCounts[t] = (tagCounts[t] || 0) + 1; + }); + } + }); + + res.status(200).json({ + verticals, + categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })), + subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })), + contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })), + tags: Object.entries(tagCounts).map(([name, count]) => ({ name, count })), + totalAssets: assets.length, + }); + } catch (err) { next(err); } + }; + + // Admin: Create Vertical + public createVertical = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { name, icon, description, color } = req.body; + if (!name) return res.status(400).json({ error: 'Name is required' }); + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, ''); + + const vertical = await prisma.vertical.create({ + data: { name, slug, icon, description, color } + }); + res.status(201).json(vertical); + } catch (err) { next(err); } + }; + + // Admin: Update Vertical + public updateVertical = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const { name, icon, description, color, isActive, orderIndex } = req.body; + const data: any = {}; + if (name !== undefined) { + data.name = name; + data.slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, ''); + } + if (icon !== undefined) data.icon = icon; + if (description !== undefined) data.description = description; + if (color !== undefined) data.color = color; + if (isActive !== undefined) data.isActive = isActive; + if (orderIndex !== undefined) data.orderIndex = orderIndex; + + const vertical = await prisma.vertical.update({ + where: { id }, + data, + }); + res.status(200).json(vertical); + } catch (err) { next(err); } + }; + + // Admin: Delete Vertical + public deleteVertical = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + await prisma.vertical.delete({ where: { id } }); + res.status(204).send(); + } catch (err) { next(err); } + }; +} diff --git a/Channel-Backend/src/routes/notification.routes.ts b/Channel-Backend/src/routes/notification.routes.ts new file mode 100644 index 0000000..07c8b82 --- /dev/null +++ b/Channel-Backend/src/routes/notification.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { NotificationController } from '../controllers/notification.controller'; +import { authenticate, requireRole } from '../middleware/auth.middleware'; + +const router = Router(); +const controller = new NotificationController(); + +router.post('/notify', authenticate, requireRole('ADMIN'), controller.sendAssetAnnouncement); +router.get('/logs', authenticate, requireRole('ADMIN'), controller.getNotificationLogs); + +export default router; diff --git a/Channel-Backend/src/routes/taxonomy.routes.ts b/Channel-Backend/src/routes/taxonomy.routes.ts new file mode 100644 index 0000000..2910a84 --- /dev/null +++ b/Channel-Backend/src/routes/taxonomy.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { TaxonomyController } from '../controllers/taxonomy.controller'; +import { authenticate, requireRole } from '../middleware/auth.middleware'; + +const router = Router(); +const controller = new TaxonomyController(); + +router.get('/verticals', authenticate, controller.getVerticals); +router.get('/meta', authenticate, controller.getTaxonomyMeta); + +router.post('/verticals', authenticate, requireRole('ADMIN'), controller.createVertical); +router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical); +router.delete('/verticals/:id', authenticate, requireRole('ADMIN'), controller.deleteVertical); + +export default router; diff --git a/Channel-Backend/src/services/asset.service.ts b/Channel-Backend/src/services/asset.service.ts index c1b4da3..c0c27da 100644 --- a/Channel-Backend/src/services/asset.service.ts +++ b/Channel-Backend/src/services/asset.service.ts @@ -55,7 +55,7 @@ export class AssetService { } public async createAsset(data: any) { - const { sharedOrgIds, shares, tags, ...rest } = data; + const { sharedOrgIds, shares, tags, verticalIds, ...rest } = data; // Parse tags let parsedTags: string[] = []; @@ -69,10 +69,27 @@ export class AssetService { } } + // Parse verticalIds + let parsedVerticalIds: string[] = []; + if (Array.isArray(verticalIds)) { + parsedVerticalIds = verticalIds; + } else if (typeof verticalIds === 'string' && verticalIds.trim()) { + try { + parsedVerticalIds = JSON.parse(verticalIds); + } catch { + parsedVerticalIds = verticalIds.split(',').map((id: string) => id.trim()).filter(Boolean); + } + } + const asset = await prisma.asset.create({ data: { ...rest, tags: parsedTags, + ...(parsedVerticalIds.length > 0 ? { + verticals: { + connect: parsedVerticalIds.map(id => ({ id })) + } + } : {}) } }); @@ -138,53 +155,40 @@ export class AssetService { return this.getAssetById(asset.id); } - public async getAssets(userContext?: { role: string; userId: string }) { + public async getAssets( + userContext?: { role: string; userId: string }, + filters?: { + search?: string; + verticalIds?: string[]; + contentTypes?: string[]; + subcategories?: string[]; + tags?: string[]; + sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type'; + } + ) { if (!userContext) { return []; } - if (userContext.role === 'ADMIN') { - return await prisma.asset.findMany({ - include: { - sharedWith: { - include: { - organization: { - select: { id: true, name: true } - }, - user: { - select: { id: true, email: true } - } - } - }, - downloadRequests: { - include: { - user: { - select: { id: true, email: true } - } - } - } - }, - orderBy: { createdAt: 'desc' } + const whereClause: any = {}; + + if (userContext.role !== 'ADMIN') { + // For clients/partners, find user organization first + const user = await prisma.user.findUnique({ + where: { id: userContext.userId } }); - } - // For clients/partners, find user organization first - const user = await prisma.user.findUnique({ - where: { id: userContext.userId } - }); + if (!user || !user.organizationId) { + return []; + } - if (!user || !user.organizationId) { - return []; - } + // Support comma-separated multiple groups + const userGroups = user.partnerGroup + ? user.partnerGroup.split(',').map(s => s.trim().toLowerCase()) + : []; - // Support comma-separated multiple groups - const userGroups = user.partnerGroup - ? user.partnerGroup.split(',').map(s => s.trim().toLowerCase()) - : []; - - const whereClause: any = { - status: 'published', - OR: [ + whereClause.status = 'published'; + whereClause.OR = [ { sharedWith: { some: { @@ -196,25 +200,81 @@ export class AssetService { } } } - ] - }; + ]; - if (userGroups.length > 0) { - whereClause.OR.push({ - assetGroups: { - some: { - name: { - in: userGroups, - mode: 'insensitive' + if (userGroups.length > 0) { + whereClause.OR.push({ + assetGroups: { + some: { + name: { + in: userGroups, + mode: 'insensitive' + } } } + }); + } + } + + // Apply Filter Criteria (Additive AND logic) + const andConditions: any[] = []; + + if (filters?.search && filters.search.trim()) { + const q = filters.search.trim(); + andConditions.push({ + OR: [ + { title: { contains: q, mode: 'insensitive' } }, + { description: { contains: q, mode: 'insensitive' } }, + { subcategory: { contains: q, mode: 'insensitive' } }, + { categoryId: { contains: q, mode: 'insensitive' } }, + { tags: { has: q } }, + ] + }); + } + + if (filters?.verticalIds && filters.verticalIds.length > 0) { + andConditions.push({ + verticals: { + some: { + id: { in: filters.verticalIds } + } } }); } + if (filters?.contentTypes && filters.contentTypes.length > 0) { + andConditions.push({ + contentType: { in: filters.contentTypes } + }); + } + + if (filters?.subcategories && filters.subcategories.length > 0) { + andConditions.push({ + subcategory: { in: filters.subcategories } + }); + } + + if (filters?.tags && filters.tags.length > 0) { + andConditions.push({ + tags: { hasSome: filters.tags } + }); + } + + if (andConditions.length > 0) { + whereClause.AND = andConditions; + } + + // Order By + let orderBy: any = { createdAt: 'desc' }; + if (filters?.sortBy === 'oldest') orderBy = { createdAt: 'asc' }; + else if (filters?.sortBy === 'title_asc') orderBy = { title: 'asc' }; + else if (filters?.sortBy === 'title_desc') orderBy = { title: 'desc' }; + else if (filters?.sortBy === 'type') orderBy = { type: 'asc' }; + return await prisma.asset.findMany({ where: whereClause, include: { + verticals: true, sharedWith: { include: { organization: { @@ -225,11 +285,15 @@ export class AssetService { } } }, - downloadRequests: { + downloadRequests: userContext.role === 'ADMIN' ? { + include: { + user: { select: { id: true, email: true } } + } + } : { where: { userId: userContext.userId } } }, - orderBy: { createdAt: 'desc' } + orderBy, }); } @@ -259,7 +323,7 @@ export class AssetService { } public async updateAsset(id: string, data: any) { - const { sharedOrgIds, shares, tags, ...rest } = data; + const { shares, sharedOrgIds, tags, verticalIds, ...rest } = data; const updateData: any = { ...rest }; @@ -277,6 +341,22 @@ export class AssetService { updateData.tags = parsedTags; } + if (verticalIds !== undefined) { + let parsedVerticalIds: string[] = []; + if (Array.isArray(verticalIds)) { + parsedVerticalIds = verticalIds; + } else if (typeof verticalIds === 'string') { + try { + parsedVerticalIds = JSON.parse(verticalIds); + } catch { + parsedVerticalIds = verticalIds.split(',').map((v: string) => v.trim()).filter(Boolean); + } + } + updateData.verticals = { + set: parsedVerticalIds.map(vid => ({ id: vid })) + }; + } + await prisma.asset.update({ where: { id }, data: updateData diff --git a/Channel-Backend/src/services/mail.service.ts b/Channel-Backend/src/services/mail.service.ts index 63d970c..45bfbf3 100644 --- a/Channel-Backend/src/services/mail.service.ts +++ b/Channel-Backend/src/services/mail.service.ts @@ -66,4 +66,26 @@ export class MailService { throw err; } } + + public async sendCustomAnnouncement(options: { recipients: string[]; subject: string; messageBody: string }) { + if (!options.recipients || options.recipients.length === 0) return; + const mailOptions = { + from: process.env.SMTP_FROM || '"Tech4Biz Portal" ', + to: options.recipients.join(', '), + subject: options.subject, + html: ` +
+

${options.subject}

+
${options.messageBody}
+

Sent via Tech4Biz Channel Partner Platform

+
+ ` + }; + try { + await this.transporter.sendMail(mailOptions); + console.log(`[SMTP] Announcement email sent to ${options.recipients.length} recipients.`); + } catch (err) { + console.error('[SMTP ERROR] Failed to send announcement email:', err); + } + } } diff --git a/Channel-Frontend/src/features/assets/components/AssetAdminManagerModal.tsx b/Channel-Frontend/src/features/assets/components/AssetAdminManagerModal.tsx new file mode 100644 index 0000000..5f357fe --- /dev/null +++ b/Channel-Frontend/src/features/assets/components/AssetAdminManagerModal.tsx @@ -0,0 +1,464 @@ +import React, { useState } from 'react'; +import { X, Shield, Plus, Trash2, Send, Tag, CheckCircle2, AlertCircle, Bell } from 'lucide-react'; +import type { TaxonomyMeta, Organization, Asset } from '../../../types/assets'; +import { createVertical, deleteVertical, sendAssetAnnouncement } from '../../../services/assets-api'; + +interface AssetAdminManagerModalProps { + isOpen: boolean; + onClose: () => void; + meta: TaxonomyMeta | null; + organizations: Organization[]; + allAssets?: Asset[]; + onRefreshMeta: () => void; +} + +export const AssetAdminManagerModal: React.FC = ({ + isOpen, + onClose, + meta, + organizations, + allAssets = [], + onRefreshMeta, +}) => { + const [activeTab, setActiveTab] = useState<'verticals' | 'taxonomy' | 'announcements'>('verticals'); + const [loading, setLoading] = useState(false); + const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Inspect filter state for Taxonomy Stats interactive list + const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null); + + // New Vertical Form State + const [newVerticalName, setNewVerticalName] = useState(''); + const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6'); + const [newVerticalDesc, setNewVerticalDesc] = useState(''); + + // Announcement Form State + const [announcementTitle, setAnnouncementTitle] = useState(''); + const [announcementMsg, setAnnouncementMsg] = useState(''); + const [selectedOrgId, setSelectedOrgId] = useState('ALL'); + + if (!isOpen) return null; + + const matchingAssets = (allAssets || []).filter(asset => { + if (!inspectFilter) return false; + if (inspectFilter.type === 'Subcategory') { + return (asset.subcategory || asset.categoryId || '').toLowerCase() === inspectFilter.name.toLowerCase(); + } + if (inspectFilter.type === 'Vertical') { + return asset.verticals?.some(v => v.id === inspectFilter.id || v.name.toLowerCase() === inspectFilter.name.toLowerCase()); + } + return false; + }); + + const handleCreateVertical = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newVerticalName.trim()) return; + setLoading(true); + try { + await createVertical({ + name: newVerticalName.trim(), + color: newVerticalColor, + description: newVerticalDesc.trim() || undefined, + icon: 'Shield', + }); + setStatusMsg({ type: 'success', text: `Vertical "${newVerticalName}" created successfully!` }); + setNewVerticalName(''); + setNewVerticalDesc(''); + onRefreshMeta(); + } catch (err: any) { + setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create vertical' }); + } finally { + setLoading(false); + } + }; + + const handleDeleteVertical = async (id: string, name: string) => { + if (!window.confirm(`Are you sure you want to delete vertical "${name}"?`)) return; + setLoading(true); + try { + await deleteVertical(id); + setStatusMsg({ type: 'success', text: `Vertical "${name}" deleted` }); + onRefreshMeta(); + } catch (err: any) { + setStatusMsg({ type: 'error', text: 'Failed to delete vertical' }); + } finally { + setLoading(false); + } + }; + + const handleSendAnnouncement = async (e: React.FormEvent) => { + e.preventDefault(); + if (!announcementTitle.trim() || !announcementMsg.trim()) return; + setLoading(true); + try { + await sendAssetAnnouncement({ + title: announcementTitle.trim(), + message: announcementMsg.trim(), + targetOrgIds: selectedOrgId === 'ALL' ? ['ALL'] : [selectedOrgId], + }); + setStatusMsg({ type: 'success', text: 'Announcement dispatched to partner organizations!' }); + setAnnouncementTitle(''); + setAnnouncementMsg(''); + } catch (err: any) { + setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to send announcement' }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + {/* Modal Header */} +
+
+
+ +
+
+

+ Taxonomy & Partner Announcements Control Panel +

+

+ Manage industry verticals, taxonomy metadata, and partner communications +

+
+
+ +
+ + {/* Tab Navigation */} +
+ + + +
+ + {/* Status Message */} + {statusMsg && ( +
+ {statusMsg.type === 'success' ? : } + {statusMsg.text} +
+ )} + + {/* Modal Body */} +
+ + {/* TAB 1: Verticals */} + {activeTab === 'verticals' && ( +
+ {/* Create Vertical Form */} +
+

+ + Add New Industry Vertical +

+
+
+ + setNewVerticalName(e.target.value)} + className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-slate-400" + /> +
+
+ +
+ setNewVerticalColor(e.target.value)} + className="w-9 h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer" + /> + setNewVerticalColor(e.target.value)} + className="w-full px-2 py-1.5 text-xs font-mono rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + /> +
+
+
+
+ setNewVerticalDesc(e.target.value)} + className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" + /> +
+
+ +
+
+ + {/* Verticals Table */} +
+

+ Active Verticals ({meta?.verticals.length || 0}) +

+
+ {(meta?.verticals || []).map(v => ( +
+
+ +
+
+ {v.name} + + {v._count?.assets ?? 0} assets + +
+ {v.description &&
{v.description}
} +
+
+ +
+ ))} +
+
+
+ )} + + {/* TAB 2: Taxonomy Stats */} + {activeTab === 'taxonomy' && ( +
+
+
+
{meta?.totalAssets || 0}
+
Total Catalog Assets
+
+
+
{meta?.verticals.length || 0}
+
Industry Verticals
+
+
+
{meta?.subcategories.length || 0}
+
Subcategories
+
+
+
{meta?.tags.length || 0}
+
Unique Tags
+
+
+ +
+

+ Subcategory Breakdown (Click to Inspect Records) +

+
+ {(meta?.subcategories || []).map(s => ( + + ))} +
+
+ +
+

+ Vertical Domain Distribution +

+
+ {(meta?.verticals || []).map(v => ( + + ))} +
+
+ + {/* Inspect Filter Asset List Details */} + {inspectFilter && ( +
+
+
+ + Inspecting {inspectFilter.type}: {inspectFilter.name} +
+ +
+
+ {matchingAssets.length === 0 ? ( +
No assets tagged with this {inspectFilter.type.toLowerCase()} yet.
+ ) : ( + matchingAssets.map(asset => ( +
+
+
{asset.title}
+
{asset.subcategory || asset.type} • {asset.url}
+
+ + View Asset + +
+ )) + )} +
+
+ )} +
+ )} + + {/* TAB 3: Announcements */} + {activeTab === 'announcements' && ( +
+
+ + +
+ +
+ + setAnnouncementTitle(e.target.value)} + className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" + /> +
+ +
+ +