diff --git a/Channel-Backend/prisma/schema.prisma b/Channel-Backend/prisma/schema.prisma index 193de41..cced4cb 100644 --- a/Channel-Backend/prisma/schema.prisma +++ b/Channel-Backend/prisma/schema.prisma @@ -17,18 +17,19 @@ enum DocumentType { } model Organization { - id String @id @default(uuid()) - name String - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - users User[] + id String @id @default(uuid()) + name String + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] + sharedAssets SharedAsset[] } model User { - id String @id @default(uuid()) - email String @unique - passwordHash String + id String @id @default(uuid()) + email String @unique + passwordHash String role Role @default(PARTNER_USER) mfaEnabled Boolean @default(true) mfaSecret String? @@ -37,22 +38,62 @@ model User { organizationId String? onboardingStatus String @default("PENDING_ONBOARDING") organization Organization? @relation(fields: [organizationId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - acceptances LegalAcceptance[] - auditLogs AuditLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + acceptances LegalAcceptance[] + auditLogs AuditLog[] + sharedAssets SharedAsset[] + downloadRequests DownloadRequest[] } model Asset { - id String @id @default(uuid()) - title String - type String - size Int - url String - version Int @default(1) - uploadedBy String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + title String + type String + size Int + url String + version Int @default(1) + uploadedBy String + description String? @db.Text + categoryId String? + subcategory String? + tags String[] + downloadsCount Int @default(0) + githubUrl String? + status String @default("published") + isDownloadable Boolean @default(true) + folderId String? + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + sharedWith SharedAsset[] + downloadRequests DownloadRequest[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model SharedAsset { + id String @id @default(uuid()) + assetId String + organizationId String + userId String? + createdAt DateTime @default(now()) + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([assetId, organizationId, userId]) +} + +model DownloadRequest { + id String @id @default(uuid()) + assetId String + userId String + status String @default("PENDING") // PENDING, APPROVED, REJECTED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([assetId, userId]) } model Folder { @@ -61,28 +102,30 @@ model Folder { parentId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + assets Asset[] } model LegalDocument { id String @id @default(uuid()) type DocumentType version String - content String @db.Text + content String isActive Boolean @default(false) + pdfUrl String? createdAt DateTime @default(now()) acceptances LegalAcceptance[] } model LegalAcceptance { - id String @id @default(uuid()) - docId String - userId String - ipAddress String - signatureHash String? - documentUrl String? - acceptedAt DateTime @default(now()) - document LegalDocument @relation(fields: [docId], references: [id]) - user User @relation(fields: [userId], references: [id]) + id String @id @default(uuid()) + docId String + userId String + ipAddress String + signatureHash String? + documentUrl String? + acceptedAt DateTime @default(now()) + document LegalDocument @relation(fields: [docId], references: [id]) + user User @relation(fields: [userId], references: [id]) } model AuditLog { diff --git a/Channel-Backend/seed.ts b/Channel-Backend/seed.ts index fbab101..5e50ffc 100644 --- a/Channel-Backend/seed.ts +++ b/Channel-Backend/seed.ts @@ -1,27 +1,185 @@ import dotenv from 'dotenv'; dotenv.config(); import prisma from './src/utils/db'; +import bcrypt from 'bcrypt'; async function seed() { - await prisma.legalDocument.create({ - data: { - type: 'NDA', - version: '1.0', - content: 'This is the standard Non-Disclosure Agreement content...', - isActive: true, - } - }); + console.log('Starting database seeding...'); - await prisma.legalDocument.create({ - data: { - type: 'MSA', - version: '1.0', - content: 'This is the standard Master Services Agreement content...', - isActive: true, - } + // 1. Seed Active Legal Documents + let ndaDoc = await prisma.legalDocument.findFirst({ + where: { type: 'NDA', version: '1.0' } }); + if (!ndaDoc) { + ndaDoc = await prisma.legalDocument.create({ + data: { + type: 'NDA', + version: '1.0', + content: 'This is the standard Non-Disclosure Agreement content. By signing this document, you agree to keep all technical and business materials confidential.', + isActive: true, + } + }); + console.log('Seeded NDA Document.'); + } else { + console.log('NDA Document already exists.'); + } - console.log('Documents seeded.'); + let msaDoc = await prisma.legalDocument.findFirst({ + where: { type: 'MSA', version: '1.0' } + }); + if (!msaDoc) { + msaDoc = await prisma.legalDocument.create({ + data: { + type: 'MSA', + version: '1.0', + content: 'This is the standard Master Services Agreement content. It defines the core relationship, deliverables, and service levels between Tech4Biz and the partner.', + isActive: true, + } + }); + console.log('Seeded MSA Document.'); + } else { + console.log('MSA Document already exists.'); + } + + // 2. Hash Password + const passwordHash = await bcrypt.hash('Password123', 10); + + // Create default Partner organization + let partnerOrg = await prisma.organization.findFirst({ + where: { name: 'PARTNER' } + }); + if (!partnerOrg) { + partnerOrg = await prisma.organization.create({ + data: { name: 'PARTNER' } + }); + console.log('Seeded PARTNER organization.'); + } + + // 3. Seed Admin + const adminEmail = 'admin@tech4biz.com'; + let adminUser = await prisma.user.findUnique({ where: { email: adminEmail } }); + if (!adminUser) { + adminUser = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + role: 'ADMIN', + onboardingStatus: 'APPROVED', + mfaEnabled: false, + } + }); + console.log(`Seeded Admin User: ${adminEmail}`); + } else { + console.log(`Admin User already exists: ${adminEmail}`); + } + + // 4. Seed Partner 1: Awaiting onboarding + const onboardingEmail = 'pending-onboarding@partner.com'; + let onboardingUser = await prisma.user.findUnique({ where: { email: onboardingEmail } }); + if (!onboardingUser) { + onboardingUser = await prisma.user.create({ + data: { + email: onboardingEmail, + passwordHash, + role: 'PARTNER_USER', + onboardingStatus: 'PENDING_ONBOARDING', + mfaEnabled: false, + organizationId: partnerOrg.id + } + }); + console.log(`Seeded Partner (Pending Onboarding): ${onboardingEmail}`); + } else { + console.log(`Partner (Pending Onboarding) already exists: ${onboardingEmail}`); + } + + // 5. Seed Partner 2: Awaiting admin approval (Signed NDA & MSA) + const pendingApprovalEmail = 'pending-approval@partner.com'; + let pendingApprovalUser = await prisma.user.findUnique({ where: { email: pendingApprovalEmail } }); + if (!pendingApprovalUser) { + pendingApprovalUser = await prisma.user.create({ + data: { + email: pendingApprovalEmail, + passwordHash, + role: 'PARTNER_USER', + onboardingStatus: 'PENDING_APPROVAL', + mfaEnabled: false, + organizationId: partnerOrg.id + } + }); + console.log(`Seeded Partner (Pending Approval): ${pendingApprovalEmail}`); + + // Seed LegalAcceptance for NDA + await prisma.legalAcceptance.create({ + data: { + docId: ndaDoc.id, + userId: pendingApprovalUser.id, + ipAddress: '127.0.0.1', + signatureHash: 'sha256-dummyndaaccept1234567890abcdef', + acceptedAt: new Date() + } + }); + // Seed LegalAcceptance for MSA + await prisma.legalAcceptance.create({ + data: { + docId: msaDoc.id, + userId: pendingApprovalUser.id, + ipAddress: '127.0.0.1', + signatureHash: 'sha256-dummymsaaccept1234567890abcdef', + acceptedAt: new Date() + } + }); + console.log('Seeded Legal Acceptances for NDA/MSA for pending-approval partner.'); + } else { + console.log(`Partner (Pending Approval) already exists: ${pendingApprovalEmail}`); + } + + // 6. Seed Partner 3: Fully active & approved + const activeEmail = 'active-partner@partner.com'; + let activeUser = await prisma.user.findUnique({ where: { email: activeEmail } }); + if (!activeUser) { + activeUser = await prisma.user.create({ + data: { + email: activeEmail, + passwordHash, + role: 'PARTNER_USER', + onboardingStatus: 'APPROVED', + mfaEnabled: false, + organizationId: partnerOrg.id + } + }); + console.log(`Seeded Partner (Approved): ${activeEmail}`); + + // Seed LegalAcceptance for NDA + await prisma.legalAcceptance.create({ + data: { + docId: ndaDoc.id, + userId: activeUser.id, + ipAddress: '127.0.0.1', + signatureHash: 'sha256-dummyndaacceptactive987654321', + acceptedAt: new Date() + } + }); + // Seed LegalAcceptance for MSA + await prisma.legalAcceptance.create({ + data: { + docId: msaDoc.id, + userId: activeUser.id, + ipAddress: '127.0.0.1', + signatureHash: 'sha256-dummymsaacceptactive987654321', + acceptedAt: new Date() + } + }); + console.log('Seeded Legal Acceptances for NDA/MSA for active partner.'); + } else { + console.log(`Partner (Approved) already exists: ${activeEmail}`); + } + + console.log('Seeding completed successfully.'); } -seed().catch(console.error).finally(() => prisma.$disconnect()); +seed() + .catch((err) => { + console.error('Seeding failed:', err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/Channel-Backend/src/app.ts b/Channel-Backend/src/app.ts index f9540fe..e900b90 100644 --- a/Channel-Backend/src/app.ts +++ b/Channel-Backend/src/app.ts @@ -16,7 +16,17 @@ import legalRoutes from './routes/legal.routes'; const app: Express = express(); const PORT = process.env.PORT || 5000; -app.use(helmet()); +app.use(helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: { + useDefaults: false, + directives: { + "default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc, + "frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000"], + }, + }, + frameguard: false, +})); app.use(cors({ origin: true, credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); diff --git a/Channel-Backend/src/controllers/asset.controller.ts b/Channel-Backend/src/controllers/asset.controller.ts index d9c08eb..2856b5b 100644 --- a/Channel-Backend/src/controllers/asset.controller.ts +++ b/Channel-Backend/src/controllers/asset.controller.ts @@ -1,4 +1,4 @@ -import { Request, Response, NextFunction } from 'express'; +import { Response, NextFunction } from 'express'; import { AssetService } from '../services/asset.service'; import { AuthRequest } from '../middleware/auth.middleware'; @@ -7,32 +7,123 @@ export class AssetController { public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { try { - if (!req.file) throw new Error('No file uploaded'); + const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url'; + + if (!isUrlAsset && !req.file) { + throw new Error('No file uploaded'); + } const uploaderId = req.user?.userId || 'system'; - const asset = await this.assetService.createAsset({ - title: req.body.title || req.file.originalname, - type: req.file.mimetype, - size: req.file.size, - url: `/uploads/${req.file.filename}`, - uploadedBy: uploaderId, - }); + // Parse shares if present + let shares = req.body.shares; + if (typeof shares === 'string' && shares.trim()) { + try { shares = JSON.parse(shares); } catch { shares = undefined; } + } + + const assetData = { + title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'), + type: isUrlAsset ? 'url' : req.file!.mimetype, + size: isUrlAsset ? 0 : req.file!.size, + url: isUrlAsset ? req.body.url : `/uploads/${req.file!.filename}`, + uploadedBy: uploaderId, + description: req.body.description || null, + categoryId: req.body.categoryId || null, + subcategory: req.body.subcategory || null, + tags: req.body.tags || [], + githubUrl: req.body.githubUrl || null, + status: req.body.status || 'published', + isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true, + shares, + sharedOrgIds: req.body.sharedOrgIds || null, + }; + + const asset = await this.assetService.createAsset(assetData); res.status(201).json(asset); } catch (err) { next(err); } } - public listAssets = async (req: Request, res: Response, next: NextFunction) => { + public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const assets = await this.assetService.getAssets(); + const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined; + const assets = await this.assetService.getAssets(userContext); res.status(200).json(assets); } catch(err) { next(err); } } - public deleteAsset = async (req: Request, res: Response, next: NextFunction) => { + public getAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const asset = await this.assetService.getAssetById(req.params.id); + if (!asset) { + return res.status(404).json({ error: 'Asset not found' }); + } + res.status(200).json(asset); + } catch (err) { next(err); } + } + + public updateAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const asset = await this.assetService.updateAsset(req.params.id, req.body); + res.status(200).json(asset); + } catch (err) { next(err); } + } + + public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { organizationIds } = req.body; + if (!Array.isArray(organizationIds)) { + return res.status(400).json({ error: 'organizationIds must be an array' }); + } + const asset = await this.assetService.shareAsset(req.params.id, organizationIds); + res.status(200).json(asset); + } catch (err) { next(err); } + } + + public unshareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { organizationIds } = req.body; + if (!Array.isArray(organizationIds)) { + return res.status(400).json({ error: 'organizationIds must be an array' }); + } + const asset = await this.assetService.unshareAsset(req.params.id, organizationIds); + res.status(200).json(asset); + } catch (err) { next(err); } + } + + public incrementDownload = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const asset = await this.assetService.incrementDownloadCount(req.params.id); + res.status(200).json(asset); + } catch (err) { next(err); } + } + + public deleteAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { try { await this.assetService.deleteAsset(req.params.id); res.status(204).send(); } catch(err) { next(err); } } + + public requestDownload = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const request = await this.assetService.requestDownload(req.params.id, userId); + res.status(200).json(request); + } catch (err) { next(err); } + } + + public approveDownload = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const request = await this.assetService.approveDownloadRequest(req.params.requestId); + res.status(200).json(request); + } catch (err) { next(err); } + } + + public rejectDownload = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const request = await this.assetService.rejectDownloadRequest(req.params.requestId); + res.status(200).json(request); + } catch (err) { next(err); } + } } diff --git a/Channel-Backend/src/controllers/auth.controller.ts b/Channel-Backend/src/controllers/auth.controller.ts index ec630cb..74029b3 100644 --- a/Channel-Backend/src/controllers/auth.controller.ts +++ b/Channel-Backend/src/controllers/auth.controller.ts @@ -47,7 +47,7 @@ export class AuthController { res.cookie('refreshToken', result.refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', + sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days }); @@ -66,7 +66,7 @@ export class AuthController { res.cookie('refreshToken', result.refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', + sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days }); @@ -93,4 +93,19 @@ export class AuthController { res.status(200).json(partners); } catch(err) { next(err); } }; + + public getCurrentUser = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = (req as any).user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const user = await this.authService.getUserById(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + res.status(200).json(user); + } catch(err) { next(err); } + }; } + diff --git a/Channel-Backend/src/controllers/legal.controller.ts b/Channel-Backend/src/controllers/legal.controller.ts index c11158d..055a730 100644 --- a/Channel-Backend/src/controllers/legal.controller.ts +++ b/Channel-Backend/src/controllers/legal.controller.ts @@ -8,6 +8,7 @@ const docSchema = z.object({ type: z.enum(['NDA', 'MSA']), version: z.string(), content: z.string().min(1), + pdfUrl: z.string().optional().nullable(), }); export class LegalController { @@ -98,4 +99,11 @@ export class LegalController { res.status(200).json({ message: 'Partner approved successfully' }); } catch(err) { next(err); } } + + public uploadSignedDoc = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + if (!req.file) throw new Error('No file uploaded'); + res.status(200).json({ url: `/uploads/${req.file.filename}` }); + } catch (err) { next(err); } + } } diff --git a/Channel-Backend/src/routes/asset.routes.ts b/Channel-Backend/src/routes/asset.routes.ts index f346b8b..1787265 100644 --- a/Channel-Backend/src/routes/asset.routes.ts +++ b/Channel-Backend/src/routes/asset.routes.ts @@ -10,6 +10,16 @@ router.use(authenticate); router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset); router.get('/', assetController.listAssets); +router.get('/:id', assetController.getAsset); +router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset); +router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset); +router.post('/:id/unshare', requireRole('ADMIN'), assetController.unshareAsset); +router.post('/:id/download', assetController.incrementDownload); router.delete('/:id', requireRole('ADMIN'), assetController.deleteAsset); +// Download permission request management +router.post('/:id/request-download', assetController.requestDownload); +router.post('/:id/approve-download/:requestId', requireRole('ADMIN'), assetController.approveDownload); +router.post('/:id/reject-download/:requestId', requireRole('ADMIN'), assetController.rejectDownload); + export default router; diff --git a/Channel-Backend/src/routes/auth.routes.ts b/Channel-Backend/src/routes/auth.routes.ts index 2d0bfeb..610e1e3 100644 --- a/Channel-Backend/src/routes/auth.routes.ts +++ b/Channel-Backend/src/routes/auth.routes.ts @@ -11,6 +11,7 @@ router.post('/login', authController.login); router.post('/refresh', authController.refresh); // Invite Flow +router.get('/me', authenticate, authController.getCurrentUser); router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner); router.get('/invite/:token', authController.validateInvite); router.post('/invite/accept', authController.acceptInvite); diff --git a/Channel-Backend/src/routes/legal.routes.ts b/Channel-Backend/src/routes/legal.routes.ts index 7285bdf..b9e3f5d 100644 --- a/Channel-Backend/src/routes/legal.routes.ts +++ b/Channel-Backend/src/routes/legal.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { LegalController } from '../controllers/legal.controller'; import { authenticate, requireRole } from '../middleware/auth.middleware'; +import { upload } from '../middleware/upload.middleware'; const router = Router(); const legalController = new LegalController(); @@ -14,6 +15,7 @@ router.post('/documents', requireRole('ADMIN'), legalController.create); router.get('/documents/active/:type', legalController.getActive); router.post('/accept', legalController.accept); router.post('/sign', legalController.sign); +router.post('/upload', upload.single('file'), legalController.uploadSignedDoc); router.get('/my-acceptances', legalController.myAcceptances); // Admin Approval Routes diff --git a/Channel-Backend/src/services/asset.service.ts b/Channel-Backend/src/services/asset.service.ts index f5da714..0b89304 100644 --- a/Channel-Backend/src/services/asset.service.ts +++ b/Channel-Backend/src/services/asset.service.ts @@ -2,20 +2,315 @@ import prisma from '../utils/db'; export class AssetService { public async createAsset(data: any) { - return await prisma.asset.create({ data }); + const { sharedOrgIds, shares, tags, ...rest } = data; + + // Parse tags + let parsedTags: string[] = []; + if (Array.isArray(tags)) { + parsedTags = tags; + } else if (typeof tags === 'string' && tags.trim()) { + try { + parsedTags = JSON.parse(tags); + } catch { + parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean); + } + } + + const asset = await prisma.asset.create({ + data: { + ...rest, + tags: parsedTags, + } + }); + + // Handle immediate sharing + if (shares) { + let parsedShares: any[] = []; + if (Array.isArray(shares)) { + parsedShares = shares; + } else if (typeof shares === 'string' && shares.trim()) { + try { + parsedShares = JSON.parse(shares); + } catch { + parsedShares = shares.split(',').map((id: string) => ({ organizationId: id.trim(), userId: null })).filter(s => s.organizationId); + } + } + + if (parsedShares.length > 0) { + await prisma.sharedAsset.createMany({ + data: parsedShares.map(s => ({ + assetId: asset.id, + organizationId: s.organizationId, + userId: s.userId || null, + })), + skipDuplicates: true, + }); + } + } else if (sharedOrgIds) { + let orgIds: string[] = []; + if (Array.isArray(sharedOrgIds)) { + orgIds = sharedOrgIds; + } else if (typeof sharedOrgIds === 'string' && sharedOrgIds.trim()) { + try { + orgIds = JSON.parse(sharedOrgIds); + } catch { + orgIds = sharedOrgIds.split(',').map((id: string) => id.trim()).filter(Boolean); + } + } + + if (orgIds.length > 0) { + await prisma.sharedAsset.createMany({ + data: orgIds.map(orgId => ({ + assetId: asset.id, + organizationId: orgId, + userId: null, + })), + skipDuplicates: true, + }); + } + } + + return this.getAssetById(asset.id); } - public async getAssets() { + public async getAssets(userContext?: { role: string; userId: string }) { + 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' } + }); + } + + // For clients/partners, find user organization first + const user = await prisma.user.findUnique({ + where: { id: userContext.userId } + }); + + if (!user || !user.organizationId) { + return []; + } + return await prisma.asset.findMany({ + where: { + status: 'published', + sharedWith: { + some: { + organizationId: user.organizationId, + OR: [ + { userId: null }, + { userId: user.id } + ] + } + } + }, + include: { + sharedWith: { + include: { + organization: { + select: { id: true, name: true } + }, + user: { + select: { id: true, email: true } + } + } + }, + downloadRequests: { + where: { userId: userContext.userId } + } + }, orderBy: { createdAt: 'desc' } }); } public async getAssetById(id: string) { - return await prisma.asset.findUnique({ where: { id } }); + return await prisma.asset.findUnique({ + where: { id }, + include: { + sharedWith: { + include: { + organization: { + select: { id: true, name: true } + }, + user: { + select: { id: true, email: true } + } + } + }, + downloadRequests: { + include: { + user: { + select: { id: true, email: true } + } + } + } + } + }); + } + + public async updateAsset(id: string, data: any) { + const { sharedOrgIds, shares, tags, ...rest } = data; + + const updateData: any = { ...rest }; + + if (tags !== undefined) { + let parsedTags: string[] = []; + if (Array.isArray(tags)) { + parsedTags = tags; + } else if (typeof tags === 'string') { + try { + parsedTags = JSON.parse(tags); + } catch { + parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean); + } + } + updateData.tags = parsedTags; + } + + await prisma.asset.update({ + where: { id }, + data: updateData + }); + + if (shares !== undefined) { + let parsedShares: any[] = []; + if (Array.isArray(shares)) { + parsedShares = shares; + } else if (typeof shares === 'string') { + try { + parsedShares = JSON.parse(shares); + } catch { + parsedShares = shares.split(',').map((oid: string) => ({ organizationId: oid.trim(), userId: null })).filter(s => s.organizationId); + } + } + + await prisma.sharedAsset.deleteMany({ where: { assetId: id } }); + if (parsedShares.length > 0) { + await prisma.sharedAsset.createMany({ + data: parsedShares.map(s => ({ + assetId: id, + organizationId: s.organizationId, + userId: s.userId || null, + })), + skipDuplicates: true + }); + } + } else if (sharedOrgIds !== undefined) { + let orgIds: string[] = []; + if (Array.isArray(sharedOrgIds)) { + orgIds = sharedOrgIds; + } else if (typeof sharedOrgIds === 'string') { + try { + orgIds = JSON.parse(sharedOrgIds); + } catch { + orgIds = sharedOrgIds.split(',').map((oid: string) => oid.trim()).filter(Boolean); + } + } + + await prisma.sharedAsset.deleteMany({ where: { assetId: id } }); + if (orgIds.length > 0) { + await prisma.sharedAsset.createMany({ + data: orgIds.map(orgId => ({ + assetId: id, + organizationId: orgId, + userId: null, + })), + skipDuplicates: true + }); + } + } + + return this.getAssetById(id); + } + + public async shareAsset(assetId: string, organizationIds: string[]) { + await prisma.sharedAsset.createMany({ + data: organizationIds.map(orgId => ({ + assetId, + organizationId: orgId, + userId: null, + })), + skipDuplicates: true + }); + return this.getAssetById(assetId); + } + + public async unshareAsset(assetId: string, organizationIds: string[]) { + await prisma.sharedAsset.deleteMany({ + where: { + assetId, + organizationId: { in: organizationIds }, + userId: null + } + }); + return this.getAssetById(assetId); + } + + public async incrementDownloadCount(id: string) { + return await prisma.asset.update({ + where: { id }, + data: { + downloadsCount: { increment: 1 } + } + }); } public async deleteAsset(id: string) { return await prisma.asset.delete({ where: { id } }); } + + // Download Requests Access Methods + public async requestDownload(assetId: string, userId: string) { + const existing = await prisma.downloadRequest.findFirst({ + where: { assetId, userId } + }); + if (existing) { + return await prisma.downloadRequest.update({ + where: { id: existing.id }, + data: { status: 'PENDING' } + }); + } + return await prisma.downloadRequest.create({ + data: { + assetId, + userId, + status: 'PENDING' + } + }); + } + + public async approveDownloadRequest(requestId: string) { + return await prisma.downloadRequest.update({ + where: { id: requestId }, + data: { status: 'APPROVED' } + }); + } + + public async rejectDownloadRequest(requestId: string) { + return await prisma.downloadRequest.update({ + where: { id: requestId }, + data: { status: 'REJECTED' } + }); + } } diff --git a/Channel-Backend/src/services/auth.service.ts b/Channel-Backend/src/services/auth.service.ts index cf6a26a..18c1ec1 100644 --- a/Channel-Backend/src/services/auth.service.ts +++ b/Channel-Backend/src/services/auth.service.ts @@ -4,39 +4,68 @@ import jwt from 'jsonwebtoken'; import { AppError } from '../utils/errors'; export class AuthService { + private async getOrCreateOrganizationForEmail(email: string) { + const domain = email.split('@')[1]; + if (!domain) return null; + + // Ignore generic/public emails or treat them as their own organization + const name = domain.split('.')[0].toUpperCase(); + if (!name) return null; + + let org = await prisma.organization.findFirst({ + where: { name } + }); + + if (!org) { + org = await prisma.organization.create({ + data: { name } + }); + } + return org.id; + } + public async register(data: any) { const existing = await prisma.user.findUnique({ where: { email: data.email } }); if (existing) throw new AppError('Email already in use', 400); + let orgId = data.organizationId || null; + if (!orgId && data.role !== 'ADMIN') { + orgId = await this.getOrCreateOrganizationForEmail(data.email); + } + const passwordHash = await bcrypt.hash(data.password, 10); const user = await prisma.user.create({ data: { email: data.email, passwordHash, role: data.role || 'PARTNER_USER', - organizationId: data.organizationId || null, + organizationId: orgId, onboardingStatus: data.role === 'ADMIN' ? 'APPROVED' : 'PENDING_ONBOARDING' } }); - const { passwordHash: _, ...userWithoutPassword } = user; - return userWithoutPassword; + return this.getUserById(user.id); } public async invitePartner(email: string, organizationId?: string) { const existing = await prisma.user.findUnique({ where: { email } }); if (existing) throw new AppError('Email already in use', 400); + let orgId = organizationId || null; + if (!orgId) { + orgId = await this.getOrCreateOrganizationForEmail(email); + } + const crypto = require('crypto'); const inviteToken = crypto.randomBytes(32).toString('hex'); const inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - const user = await prisma.user.create({ + await prisma.user.create({ data: { email, passwordHash: '', // Set on accept role: 'PARTNER_USER', - organizationId: organizationId || null, + organizationId: orgId, inviteToken, inviteTokenExp, onboardingStatus: 'PENDING_ONBOARDING', @@ -76,8 +105,8 @@ export class AuthService { const accessToken = jwt.sign({ userId: updatedUser.id, role: updatedUser.role }, secret, { expiresIn: '15m' }); const refreshToken = jwt.sign({ userId: updatedUser.id }, secret, { expiresIn: '7d' }); - const { passwordHash: _, ...userWithoutPassword } = updatedUser; - return { user: userWithoutPassword, accessToken, refreshToken }; + const userWithOrg = await this.getUserById(updatedUser.id); + return { user: userWithOrg, accessToken, refreshToken }; } public async login(email: string, passwordString: string) { @@ -95,8 +124,8 @@ export class AuthService { const accessToken = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' }); const refreshToken = jwt.sign({ userId: user.id }, secret, { expiresIn: '7d' }); - const { passwordHash: _, ...userWithoutPassword } = user; - return { user: userWithoutPassword, accessToken, refreshToken }; + const userWithOrg = await this.getUserById(user.id); + return { user: userWithOrg, accessToken, refreshToken }; } public async refresh(refreshToken: string) { @@ -107,7 +136,8 @@ export class AuthService { if (!user) throw new AppError('Invalid refresh token', 401); const newAccessToken = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' }); - return { accessToken: newAccessToken }; + const userWithOrg = await this.getUserById(user.id); + return { accessToken: newAccessToken, user: userWithOrg }; } catch(err) { throw new AppError('Invalid or expired refresh token', 401); } @@ -128,4 +158,60 @@ export class AuthService { orderBy: { createdAt: 'desc' }, }); } + + public async getUserById(id: string) { + let user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + email: true, + role: true, + mfaEnabled: true, + organizationId: true, + onboardingStatus: true, + createdAt: true, + updatedAt: true, + organization: { + select: { + id: true, + name: true, + status: true, + } + } + } + }); + + if (user && user.role === 'PARTNER_USER' && !user.organizationId) { + const orgId = await this.getOrCreateOrganizationForEmail(user.email); + if (orgId) { + await prisma.user.update({ + where: { id }, + data: { organizationId: orgId } + }); + // refetch to get populated organization relation + user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + email: true, + role: true, + mfaEnabled: true, + organizationId: true, + onboardingStatus: true, + createdAt: true, + updatedAt: true, + organization: { + select: { + id: true, + name: true, + status: true, + } + } + } + }); + } + } + + return user; + } } diff --git a/Channel-Backend/src/services/legal.service.ts b/Channel-Backend/src/services/legal.service.ts index cc3a94d..4087755 100644 --- a/Channel-Backend/src/services/legal.service.ts +++ b/Channel-Backend/src/services/legal.service.ts @@ -6,6 +6,7 @@ export class LegalService { type: DocumentType; version: string; content: string; + pdfUrl?: string | null; }) { // Deprecate older active versions of the same type if (data.type) { diff --git a/Channel-Backend/src/services/organization.service.ts b/Channel-Backend/src/services/organization.service.ts index 56432f3..5f9db81 100644 --- a/Channel-Backend/src/services/organization.service.ts +++ b/Channel-Backend/src/services/organization.service.ts @@ -8,6 +8,13 @@ export class OrganizationService { public async getAll() { return await prisma.organization.findMany({ include: { + users: { + select: { + id: true, + email: true, + role: true, + } + }, _count: { select: { users: true } } diff --git a/Channel-Frontend/src/App.tsx b/Channel-Frontend/src/App.tsx index 68aff5e..6d993cc 100644 --- a/Channel-Frontend/src/App.tsx +++ b/Channel-Frontend/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { router } from "./app/router"; import { useEffect } from 'react'; import { useThemeStore } from './hooks/use-theme'; +import { useAuthStore } from './hooks/use-auth'; const queryClient = new QueryClient({ defaultOptions: { @@ -12,10 +13,29 @@ const queryClient = new QueryClient({ export const App = () => { const initTheme = useThemeStore(state => state.initTheme); + const { isInitializing, checkAuth } = useAuthStore(); useEffect(() => { initTheme(); - }, [initTheme]); + checkAuth(); + }, [initTheme, checkAuth]); + + if (isInitializing) { + return ( +
+
+
+
+
+
+
+

Initializing

+

Securing connection...

+
+
+
+ ); + } return ( @@ -23,3 +43,4 @@ export const App = () => { ); }; +export default App; diff --git a/Channel-Frontend/src/app/layouts/AdminLayout.tsx b/Channel-Frontend/src/app/layouts/AdminLayout.tsx index eafd713..ac55478 100644 --- a/Channel-Frontend/src/app/layouts/AdminLayout.tsx +++ b/Channel-Frontend/src/app/layouts/AdminLayout.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useThemeStore } from '../../hooks/use-theme'; import { useAuthStore } from '../../hooks/use-auth'; import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { axiosInstance } from '../../services/axios'; export const AdminLayout: React.FC = () => { const { user, logout } = useAuthStore(); @@ -11,39 +12,58 @@ export const AdminLayout: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); + const [pendingCount, setPendingCount] = useState(0); const handleLogout = () => { logout(); navigate('/login'); }; + useEffect(() => { + const fetchPendingCount = async () => { + try { + const res = await axiosInstance.get('/legal/pending'); + setPendingCount(res.data.length); + } catch (err) { + console.error('Failed to fetch pending count', err); + } + }; + + fetchPendingCount(); + + // Poll every 10s for real-time admin indicators + const interval = setInterval(fetchPendingCount, 10000); + return () => clearInterval(interval); + }, []); + const navItems = [ { name: 'Partners', path: '/admin/partners', icon: Users }, { name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck }, + { name: 'Legal Templates', path: '/admin/legal', icon: ShieldCheck }, { name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 }, { name: 'Analytics', path: '/admin/analytics', icon: BarChart3 }, { name: 'Blog CMS', path: '/admin/blog', icon: BookCopy } ]; return ( -
+
{/* ── Desktop Sidebar ── */} -