diff --git a/Channel-Backend/package-lock.json b/Channel-Backend/package-lock.json index 6e5a1de..b447c1a 100644 --- a/Channel-Backend/package-lock.json +++ b/Channel-Backend/package-lock.json @@ -13,6 +13,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcrypt": "^6.0.0", + "cheerio": "^1.2.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -20,6 +21,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.2.0", + "nodemailer": "^9.0.3", "pg": "^8.22.0", "zod": "^4.4.3" }, @@ -31,6 +33,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.2.0", "@types/node": "^20.12.7", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.20.0", "nodemon": "^3.1.0", "prisma": "^7.8.0", @@ -1055,6 +1058,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -1291,6 +1304,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -1469,6 +1488,48 @@ "pnpm": ">=8" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1610,6 +1671,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1690,6 +1779,61 @@ "node": ">=0.3.1" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1761,6 +1905,43 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -2172,6 +2353,37 @@ "node": ">=16.9.0" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2621,6 +2833,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -2685,6 +2906,18 @@ "node": ">=0.10.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2725,6 +2958,55 @@ "node": ">= 0.8" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3574,6 +3856,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3635,6 +3926,40 @@ "node": ">= 0.8" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/Channel-Backend/package.json b/Channel-Backend/package.json index 655c6e7..7c6151c 100644 --- a/Channel-Backend/package.json +++ b/Channel-Backend/package.json @@ -14,6 +14,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcrypt": "^6.0.0", + "cheerio": "^1.2.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -21,6 +22,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.2.0", + "nodemailer": "^9.0.3", "pg": "^8.22.0", "zod": "^4.4.3" }, @@ -32,6 +34,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.2.0", "@types/node": "^20.12.7", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.20.0", "nodemon": "^3.1.0", "prisma": "^7.8.0", diff --git a/Channel-Backend/prisma/schema.prisma b/Channel-Backend/prisma/schema.prisma index 6667253..03fdde0 100644 --- a/Channel-Backend/prisma/schema.prisma +++ b/Channel-Backend/prisma/schema.prisma @@ -37,6 +37,11 @@ model User { inviteTokenExp DateTime? organizationId String? onboardingStatus String @default("PENDING_ONBOARDING") + partnerGroup String? + assignedNdaId String? + assignedMsaId String? + assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull) + assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull) organization Organization? @relation(fields: [organizationId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -62,10 +67,14 @@ model Asset { githubUrl String? status String @default("published") isDownloadable Boolean @default(true) + thumbnailUrl String? + problemStatement String? @db.Text + solution String? @db.Text folderId String? folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) sharedWith SharedAsset[] downloadRequests DownloadRequest[] + assetGroups AssetGroup[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -106,14 +115,16 @@ model Folder { } model LegalDocument { - id String @id @default(uuid()) - type DocumentType - version String - content String - isActive Boolean @default(false) - pdfUrl String? - createdAt DateTime @default(now()) - acceptances LegalAcceptance[] + id String @id @default(uuid()) + type DocumentType + version String + content String + isActive Boolean @default(false) + pdfUrl String? + createdAt DateTime @default(now()) + acceptances LegalAcceptance[] + assignedNdaUsers User[] @relation("AssignedNda") + assignedMsaUsers User[] @relation("AssignedMsa") } model LegalAcceptance { @@ -152,3 +163,12 @@ model BlogPost { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model AssetGroup { + id String @id @default(uuid()) + name String @unique + description String? + assets Asset[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/Channel-Backend/seed.ts b/Channel-Backend/seed.ts index 6e106c8..b390eb9 100644 --- a/Channel-Backend/seed.ts +++ b/Channel-Backend/seed.ts @@ -73,67 +73,7 @@ async function seed() { 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 + // 4. Seed Partner: Fully active & approved const activeEmail = 'active-partner@partner.com'; let activeUser = await prisma.user.findUnique({ where: { email: activeEmail } }); if (!activeUser) { diff --git a/Channel-Backend/src/app.ts b/Channel-Backend/src/app.ts index 6027189..35db76c 100644 --- a/Channel-Backend/src/app.ts +++ b/Channel-Backend/src/app.ts @@ -18,6 +18,7 @@ import legalRoutes from './routes/legal.routes'; import blogRoutes from './routes/blog.routes'; import { ensureBucketExists } from './utils/s3'; +import { originStorage } from './utils/origin-storage'; const app: Express = express(); const PORT = process.env.PORT || 5000; @@ -38,6 +39,30 @@ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); +// Capture request origin and run in AsyncLocalStorage context +app.use((req: Request, res: Response, next: NextFunction) => { + let clientOrigin: string | undefined; + const originHeader = req.get('origin'); + if (originHeader) { + clientOrigin = originHeader; + } else { + const refererHeader = req.get('referer'); + if (refererHeader) { + try { + clientOrigin = new URL(refererHeader).origin; + } catch { + // ignore invalid urls + } + } + } + + if (clientOrigin) { + originStorage.run(clientOrigin, next); + } else { + next(); + } +}); + // Dynamic MinIO object delivery handler app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunction) => { try { diff --git a/Channel-Backend/src/controllers/asset.controller.ts b/Channel-Backend/src/controllers/asset.controller.ts index b7d9abd..52c71ec 100644 --- a/Channel-Backend/src/controllers/asset.controller.ts +++ b/Channel-Backend/src/controllers/asset.controller.ts @@ -3,14 +3,27 @@ import path from 'path'; import { PutObjectCommand } from '@aws-sdk/client-s3'; import { s3Client, BUCKET_NAME } from '../utils/s3'; import { AssetService } from '../services/asset.service'; +import { ScraperService } from '../services/scraper.service'; import { AuthRequest } from '../middleware/auth.middleware'; export class AssetController { private assetService = new AssetService(); + private scraperService = new ScraperService(); + + public scrapeCaseStudy = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const url = req.query.url as string; + if (!url) { + return res.status(400).json({ error: 'URL parameter is required' }); + } + const data = await this.scraperService.scrapeCaseStudy(url); + res.status(200).json(data); + } catch (err) { next(err); } + } public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url'; + const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url' || req.body.type === 'case_study'; if (!isUrlAsset && !req.file) { throw new Error('No file uploaded'); @@ -18,6 +31,11 @@ export class AssetController { const uploaderId = req.user?.userId || 'system'; + const description = req.body.description; + if (!description || !description.trim()) { + return res.status(400).json({ error: 'Description is required' }); + } + // Parse shares if present let shares = req.body.shares; if (typeof shares === 'string' && shares.trim()) { @@ -43,7 +61,7 @@ export class AssetController { const assetData = { title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'), - type: isUrlAsset ? 'url' : req.file!.mimetype, + type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype), size: isUrlAsset ? 0 : req.file!.size, url: fileUrl, uploadedBy: uploaderId, @@ -54,6 +72,9 @@ export class AssetController { githubUrl: req.body.githubUrl || null, status: req.body.status || 'published', isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true, + thumbnailUrl: req.body.thumbnailUrl || null, + problemStatement: req.body.problemStatement || null, + solution: req.body.solution || null, shares, sharedOrgIds: req.body.sharedOrgIds || null, }; @@ -160,4 +181,51 @@ export class AssetController { res.status(200).json(request); } catch (err) { next(err); } } + + public createAssetGroup = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { name, description, assetIds } = req.body; + if (!name) { + return res.status(400).json({ error: 'Group name is required' }); + } + const group = await this.assetService.createAssetGroup(name, description, assetIds || []); + res.status(201).json(group); + } catch (err) { next(err); } + } + + public listAssetGroups = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const groups = await this.assetService.getAssetGroups(); + res.status(200).json(groups); + } catch (err) { next(err); } + } + + public deleteAssetGroup = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + await this.assetService.deleteAssetGroup(req.params.id); + res.status(204).send(); + } catch (err) { next(err); } + } + + public addAssetsToGroup = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { assetIds } = req.body; + if (!Array.isArray(assetIds)) { + return res.status(400).json({ error: 'assetIds must be an array' }); + } + const group = await this.assetService.addAssetsToGroup(req.params.id, assetIds); + res.status(200).json(group); + } catch (err) { next(err); } + } + + public removeAssetsFromGroup = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { assetIds } = req.body; + if (!Array.isArray(assetIds)) { + return res.status(400).json({ error: 'assetIds must be an array' }); + } + const group = await this.assetService.removeAssetsFromGroup(req.params.id, assetIds); + res.status(200).json(group); + } catch (err) { next(err); } + } } diff --git a/Channel-Backend/src/controllers/auth.controller.ts b/Channel-Backend/src/controllers/auth.controller.ts index 74029b3..5becd37 100644 --- a/Channel-Backend/src/controllers/auth.controller.ts +++ b/Channel-Backend/src/controllers/auth.controller.ts @@ -25,12 +25,58 @@ export class AuthController { public invitePartner = async (req: Request, res: Response, next: NextFunction) => { try { - const { email, organizationId } = z.object({ email: z.string().email(), organizationId: z.string().uuid().optional() }).parse(req.body); - const result = await this.authService.invitePartner(email, organizationId); + const { email, organizationId, partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled } = z.object({ + email: z.string().email(), + organizationId: z.string().uuid().optional(), + partnerGroup: z.string().optional().nullable(), + assignedNdaId: z.string().optional().nullable(), + assignedMsaId: z.string().optional().nullable(), + sharedAssetIds: z.array(z.string().uuid()).optional(), + mfaEnabled: z.boolean().optional(), + }).parse(req.body); + + const result = await this.authService.invitePartner(email, { + organizationId, + partnerGroup: partnerGroup || undefined, + assignedNdaId: assignedNdaId || undefined, + assignedMsaId: assignedMsaId || undefined, + sharedAssetIds, + mfaEnabled, + }); res.status(201).json({ message: 'Invite created', token: result.inviteToken }); } catch(err) { next(err); } } + public updatePartner = async (req: Request, res: Response, next: NextFunction) => { + try { + const { partnerId } = req.params; + const { partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled } = z.object({ + partnerGroup: z.string().optional().nullable(), + assignedNdaId: z.string().optional().nullable(), + assignedMsaId: z.string().optional().nullable(), + sharedAssetIds: z.array(z.string().uuid()).optional(), + mfaEnabled: z.boolean().optional(), + }).parse(req.body); + + const result = await this.authService.updatePartner(partnerId, { + partnerGroup: partnerGroup === null ? undefined : partnerGroup, + assignedNdaId: assignedNdaId === null ? undefined : assignedNdaId, + assignedMsaId: assignedMsaId === null ? undefined : assignedMsaId, + sharedAssetIds, + mfaEnabled, + }); + res.status(200).json(result); + } catch(err) { next(err); } + } + + public resendInvite = async (req: Request, res: Response, next: NextFunction) => { + try { + const { partnerId } = req.params; + const result = await this.authService.resendInvite(partnerId); + res.status(200).json(result); + } catch(err) { next(err); } + } + public validateInvite = async (req: Request, res: Response, next: NextFunction) => { try { const { token } = req.params; diff --git a/Channel-Backend/src/controllers/legal.controller.ts b/Channel-Backend/src/controllers/legal.controller.ts index 5b13872..9ac6fea 100644 --- a/Channel-Backend/src/controllers/legal.controller.ts +++ b/Channel-Backend/src/controllers/legal.controller.ts @@ -25,8 +25,12 @@ export class LegalController { public getActive = async (req: Request, res: Response, next: NextFunction) => { try { const type = req.params.type.toUpperCase() as DocumentType; - const doc = await this.legalService.getActiveDocument(type); + const userId = (req as any).user?.userId; + const doc = await this.legalService.getActiveDocument(type, userId); if (!doc) { + if (userId) { + return res.status(200).json({ skipped: true }); + } return res.status(404).json({ error: 'No active document found' }); } res.status(200).json(doc); @@ -56,7 +60,7 @@ export class LegalController { // Get active document for this type const type = documentType.toUpperCase() as DocumentType; - const activeDoc = await this.legalService.getActiveDocument(type); + const activeDoc = await this.legalService.getActiveDocument(type, userId); if (!activeDoc) { return res.status(404).json({ error: 'No active document found to sign' }); @@ -107,6 +111,13 @@ export class LegalController { } catch(err) { next(err); } } + public listAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const docs = await this.legalService.getAllDocuments(); + res.status(200).json(docs); + } catch(err) { next(err); } + } + public uploadSignedDoc = async (req: AuthRequest, res: Response, next: NextFunction) => { try { if (!req.file) throw new Error('No file uploaded'); diff --git a/Channel-Backend/src/routes/asset.routes.ts b/Channel-Backend/src/routes/asset.routes.ts index 811fbc4..2a6d120 100644 --- a/Channel-Backend/src/routes/asset.routes.ts +++ b/Channel-Backend/src/routes/asset.routes.ts @@ -9,8 +9,17 @@ const assetController = new AssetController(); router.use(authenticate); router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset); +router.get('/scrape-case-study', requireRole('ADMIN'), assetController.scrapeCaseStudy); router.get('/', assetController.listAssets); router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets); + +// Asset groups endpoints +router.get('/groups/list', assetController.listAssetGroups); +router.post('/groups/create', requireRole('ADMIN'), assetController.createAssetGroup); +router.delete('/groups/:id', requireRole('ADMIN'), assetController.deleteAssetGroup); +router.patch('/groups/:id/add-assets', requireRole('ADMIN'), assetController.addAssetsToGroup); +router.patch('/groups/:id/remove-assets', requireRole('ADMIN'), assetController.removeAssetsFromGroup); + router.get('/:id', assetController.getAsset); router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset); router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset); diff --git a/Channel-Backend/src/routes/auth.routes.ts b/Channel-Backend/src/routes/auth.routes.ts index 610e1e3..622bcdb 100644 --- a/Channel-Backend/src/routes/auth.routes.ts +++ b/Channel-Backend/src/routes/auth.routes.ts @@ -16,5 +16,7 @@ router.post('/invite', authenticate, requireRole('ADMIN'), authController.invite router.get('/invite/:token', authController.validateInvite); router.post('/invite/accept', authController.acceptInvite); router.get('/partners', authenticate, requireRole('ADMIN'), authController.listPartners); +router.put('/partners/:partnerId', authenticate, requireRole('ADMIN'), authController.updatePartner); +router.post('/partners/:partnerId/resend-invite', authenticate, requireRole('ADMIN'), authController.resendInvite); export default router; diff --git a/Channel-Backend/src/routes/legal.routes.ts b/Channel-Backend/src/routes/legal.routes.ts index b9e3f5d..8bce265 100644 --- a/Channel-Backend/src/routes/legal.routes.ts +++ b/Channel-Backend/src/routes/legal.routes.ts @@ -10,6 +10,7 @@ router.use(authenticate); // Admins create documents router.post('/documents', requireRole('ADMIN'), legalController.create); +router.get('/documents', requireRole('ADMIN'), legalController.listAll); // Anyone can view active docs and sign them router.get('/documents/active/:type', legalController.getActive); diff --git a/Channel-Backend/src/services/asset.service.ts b/Channel-Backend/src/services/asset.service.ts index 55eb94b..554aea0 100644 --- a/Channel-Backend/src/services/asset.service.ts +++ b/Channel-Backend/src/services/asset.service.ts @@ -1,6 +1,57 @@ import prisma from '../utils/db'; +import crypto from 'crypto'; +import { MailService } from './mail.service'; export class AssetService { + private mailService = new MailService(); + + public async triggerOnboardingForEligiblePartners(orgIds: string[], userIds: string[]) { + // Find all users in PENDING_ASSETS state who belong to either the userIds list or orgIds list + const users = await prisma.user.findMany({ + where: { + onboardingStatus: 'PENDING_ASSETS', + OR: [ + { id: { in: userIds } }, + { organizationId: { in: orgIds } } + ] + } + }); + + for (const user of users) { + // Find count of shared assets for this user (either user-specific or organization-level) + const count = await prisma.sharedAsset.count({ + where: { + OR: [ + { userId: user.id }, + { organizationId: user.organizationId || undefined, userId: null } + ] + } + }); + + if (count > 0) { + let inviteToken = user.inviteToken; + let inviteTokenExp = user.inviteTokenExp; + + if (!inviteToken) { + inviteToken = crypto.randomBytes(32).toString('hex'); + inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + onboardingStatus: 'PENDING_ONBOARDING', + inviteToken, + inviteTokenExp, + } + }); + + // Trigger the SMTP email + await this.mailService.sendInviteEmail(user.email, inviteToken!); + } + } + } + public async createAsset(data: any) { const { sharedOrgIds, shares, tags, ...rest } = data; @@ -24,6 +75,9 @@ export class AssetService { }); // Handle immediate sharing + const orgIdsToCheck: string[] = []; + const userIdsToCheck: string[] = []; + if (shares) { let parsedShares: any[] = []; if (Array.isArray(shares)) { @@ -45,6 +99,10 @@ export class AssetService { })), skipDuplicates: true, }); + parsedShares.forEach(s => { + if (s.organizationId) orgIdsToCheck.push(s.organizationId); + if (s.userId) userIdsToCheck.push(s.userId); + }); } } else if (sharedOrgIds) { let orgIds: string[] = []; @@ -67,9 +125,14 @@ export class AssetService { })), skipDuplicates: true, }); + orgIdsToCheck.push(...orgIds); } } + if (orgIdsToCheck.length > 0 || userIdsToCheck.length > 0) { + await this.triggerOnboardingForEligiblePartners(orgIdsToCheck, userIdsToCheck); + } + return this.getAssetById(asset.id); } @@ -193,6 +256,9 @@ export class AssetService { data: updateData }); + const orgIdsToCheck: string[] = []; + const userIdsToCheck: string[] = []; + if (shares !== undefined) { let parsedShares: any[] = []; if (Array.isArray(shares)) { @@ -215,6 +281,10 @@ export class AssetService { })), skipDuplicates: true }); + parsedShares.forEach(s => { + if (s.organizationId) orgIdsToCheck.push(s.organizationId); + if (s.userId) userIdsToCheck.push(s.userId); + }); } } else if (sharedOrgIds !== undefined) { let orgIds: string[] = []; @@ -238,9 +308,14 @@ export class AssetService { })), skipDuplicates: true }); + orgIdsToCheck.push(...orgIds); } } + if (orgIdsToCheck.length > 0 || userIdsToCheck.length > 0) { + await this.triggerOnboardingForEligiblePartners(orgIdsToCheck, userIdsToCheck); + } + return this.getAssetById(id); } @@ -253,6 +328,9 @@ export class AssetService { })), skipDuplicates: true }); + + await this.triggerOnboardingForEligiblePartners(organizationIds, []); + return this.getAssetById(assetId); } @@ -283,6 +361,10 @@ export class AssetService { skipDuplicates: true }) ]); + + const orgIds = shares.map(s => s.organizationId).filter(Boolean); + const userIds = shares.map(s => s.userId).filter(Boolean); + await this.triggerOnboardingForEligiblePartners(orgIds, userIds); } public async incrementDownloadCount(id: string) { @@ -331,4 +413,62 @@ export class AssetService { data: { status: 'REJECTED' } }); } + + public async createAssetGroup(name: string, description?: string, assetIds: string[] = []) { + return await prisma.assetGroup.create({ + data: { + name, + description, + assets: { + connect: assetIds.map(id => ({ id })) + } + }, + include: { + assets: true + } + }); + } + + public async addAssetsToGroup(groupId: string, assetIds: string[]) { + return await prisma.assetGroup.update({ + where: { id: groupId }, + data: { + assets: { + connect: assetIds.map(id => ({ id })) + } + }, + include: { + assets: true + } + }); + } + + public async removeAssetsFromGroup(groupId: string, assetIds: string[]) { + return await prisma.assetGroup.update({ + where: { id: groupId }, + data: { + assets: { + disconnect: assetIds.map(id => ({ id })) + } + }, + include: { + assets: true + } + }); + } + + public async getAssetGroups() { + return await prisma.assetGroup.findMany({ + include: { + assets: true + }, + orderBy: { createdAt: 'desc' } + }); + } + + public async deleteAssetGroup(id: string) { + return await prisma.assetGroup.delete({ + where: { id } + }); + } } diff --git a/Channel-Backend/src/services/auth.service.ts b/Channel-Backend/src/services/auth.service.ts index 18c1ec1..5ebbe8b 100644 --- a/Channel-Backend/src/services/auth.service.ts +++ b/Channel-Backend/src/services/auth.service.ts @@ -2,14 +2,17 @@ import prisma from '../utils/db'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { AppError } from '../utils/errors'; +import crypto from 'crypto'; +import { MailService } from './mail.service'; export class AuthService { + private mailService = new MailService(); + 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(); + const name = domain.toUpperCase(); if (!name) return null; let org = await prisma.organization.findFirst({ @@ -47,20 +50,50 @@ export class AuthService { return this.getUserById(user.id); } - public async invitePartner(email: string, organizationId?: string) { + public async invitePartner(email: string, options: { organizationId?: string, partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean } = {}) { const existing = await prisma.user.findUnique({ where: { email } }); if (existing) throw new AppError('Email already in use', 400); - let orgId = organizationId || null; + let orgId = options.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 - await prisma.user.create({ + const hasAssets = options.sharedAssetIds && options.sharedAssetIds.length > 0; + const onboardingStatus = hasAssets ? 'PENDING_ONBOARDING' : 'PENDING_ASSETS'; + + let assignedNdaId: string | null = null; + if (options.assignedNdaId === 'NONE') { + assignedNdaId = null; + } else if (options.assignedNdaId === undefined || options.assignedNdaId === '') { + // Resolve active default NDA + const activeNda = await prisma.legalDocument.findFirst({ + where: { type: 'NDA', isActive: true }, + select: { id: true } + }); + assignedNdaId = activeNda?.id || null; + } else { + assignedNdaId = options.assignedNdaId; + } + + let assignedMsaId: string | null = null; + if (options.assignedMsaId === 'NONE') { + assignedMsaId = null; + } else if (options.assignedMsaId === undefined || options.assignedMsaId === '') { + // Resolve active default MSA + const activeMsa = await prisma.legalDocument.findFirst({ + where: { type: 'MSA', isActive: true }, + select: { id: true } + }); + assignedMsaId = activeMsa?.id || null; + } else { + assignedMsaId = options.assignedMsaId; + } + + const user = await prisma.user.create({ data: { email, passwordHash: '', // Set on accept @@ -68,13 +101,202 @@ export class AuthService { organizationId: orgId, inviteToken, inviteTokenExp, - onboardingStatus: 'PENDING_ONBOARDING', - mfaEnabled: true, + onboardingStatus, + mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : true, + partnerGroup: options.partnerGroup || null, + assignedNdaId, + assignedMsaId, } }); - // In a real app, send email here - return { inviteToken }; + if (hasAssets && orgId) { + await prisma.sharedAsset.createMany({ + data: options.sharedAssetIds!.map(assetId => ({ + assetId, + organizationId: orgId!, + userId: user.id, + })), + skipDuplicates: true + }); + } + + let emailSent = false; + let emailError: string | undefined = undefined; + + // Trigger SMTP mail if assets are assigned + if (onboardingStatus === 'PENDING_ONBOARDING') { + try { + await this.mailService.sendInviteEmail(email, inviteToken); + emailSent = true; + } catch (err) { + console.error(`[SMTP ERROR] Non-fatal invitation mail failure: ${(err as Error).message}`); + emailError = (err as Error).message; + } + } + + return { inviteToken, emailSent, emailError }; + } + + public async updatePartner(partnerId: string, options: { partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean }) { + const user = await prisma.user.findUnique({ + where: { id: partnerId } + }); + if (!user) throw new AppError('Partner not found', 404); + + const oldStatus = user.onboardingStatus; + const orgId = user.organizationId; + + let assignedNdaId = user.assignedNdaId; + if (options.assignedNdaId !== undefined) { + if (options.assignedNdaId === 'NONE') { + assignedNdaId = null; + } else if (options.assignedNdaId === '') { + const activeNda = await prisma.legalDocument.findFirst({ + where: { type: 'NDA', isActive: true }, + select: { id: true } + }); + assignedNdaId = activeNda?.id || null; + } else { + assignedNdaId = options.assignedNdaId; + } + } + + let assignedMsaId = user.assignedMsaId; + if (options.assignedMsaId !== undefined) { + if (options.assignedMsaId === 'NONE') { + assignedMsaId = null; + } else if (options.assignedMsaId === '') { + const activeMsa = await prisma.legalDocument.findFirst({ + where: { type: 'MSA', isActive: true }, + select: { id: true } + }); + assignedMsaId = activeMsa?.id || null; + } else { + assignedMsaId = options.assignedMsaId; + } + } + + // Update basic fields + await prisma.user.update({ + where: { id: partnerId }, + data: { + partnerGroup: options.partnerGroup !== undefined ? options.partnerGroup : user.partnerGroup, + assignedNdaId, + assignedMsaId, + mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : user.mfaEnabled, + } + }); + + // Update shared assets if provided + if (options.sharedAssetIds !== undefined) { + // Clear existing shared assets for this user (both user-specific and org-wide) + if (orgId) { + await prisma.sharedAsset.deleteMany({ + where: { + OR: [ + { userId: partnerId }, + { organizationId: orgId, userId: null } + ] + } + }); + } else { + await prisma.sharedAsset.deleteMany({ + where: { userId: partnerId } + }); + } + + // Create new shared assets if any + if (options.sharedAssetIds.length > 0 && orgId) { + await prisma.sharedAsset.createMany({ + data: options.sharedAssetIds.map(assetId => ({ + assetId, + organizationId: orgId, + userId: partnerId, + })), + skipDuplicates: true + }); + } + + // Check transition from PENDING_ASSETS to PENDING_ONBOARDING + if (oldStatus === 'PENDING_ASSETS' && options.sharedAssetIds.length > 0) { + let inviteToken = user.inviteToken; + let inviteTokenExp = user.inviteTokenExp; + + if (!inviteToken) { + inviteToken = crypto.randomBytes(32).toString('hex'); + inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); + } + + await prisma.user.update({ + where: { id: partnerId }, + data: { + onboardingStatus: 'PENDING_ONBOARDING', + inviteToken, + inviteTokenExp, + } + }); + + try { + // Trigger the SMTP email since assets are now assigned! + await this.mailService.sendInviteEmail(user.email, inviteToken!); + } catch (err) { + console.error(`[SMTP ERROR] Non-fatal update transition email failure: ${(err as Error).message}`); + } + } else if (oldStatus === 'PENDING_ONBOARDING' && options.sharedAssetIds.length === 0) { + // Transition back if all assets are removed before onboarding starts + await prisma.user.update({ + where: { id: partnerId }, + data: { + onboardingStatus: 'PENDING_ASSETS' + } + }); + } + } + + return this.getUserById(partnerId); + } + + public async resendInvite(partnerId: string) { + const user = await prisma.user.findUnique({ + where: { id: partnerId } + }); + if (!user) throw new AppError('Partner not found', 404); + if (user.role === 'ADMIN') throw new AppError('Cannot send invitation to admins', 400); + if (user.onboardingStatus === 'APPROVED') { + throw new AppError('Partner is already active/approved', 400); + } + + let inviteToken = user.inviteToken; + let inviteTokenExp = user.inviteTokenExp; + + // Generate token if missing or expired + if (!inviteToken || !inviteTokenExp || inviteTokenExp < new Date()) { + inviteToken = crypto.randomBytes(32).toString('hex'); + inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + await prisma.user.update({ + where: { id: partnerId }, + data: { + inviteToken, + inviteTokenExp, + onboardingStatus: 'PENDING_ONBOARDING' + } + }); + } else if (user.onboardingStatus === 'PENDING_ASSETS') { + // Force onboarding status to PENDING_ONBOARDING + await prisma.user.update({ + where: { id: partnerId }, + data: { + onboardingStatus: 'PENDING_ONBOARDING' + } + }); + } + + try { + await this.mailService.sendInviteEmail(user.email, inviteToken); + } catch (err) { + throw new AppError(`Failed to send invitation email: ${(err as Error).message}`, 500); + } + return { success: true }; } public async validateInvite(token: string) { @@ -92,12 +314,16 @@ export class AuthService { } const passwordHash = await bcrypt.hash(passwordString, 10); + const noAgreements = !user.assignedNdaId && !user.assignedMsaId; + const newOnboardingStatus = noAgreements ? 'APPROVED' : user.onboardingStatus; + const updatedUser = await prisma.user.update({ where: { id: user.id }, data: { passwordHash, inviteToken: null, inviteTokenExp: null, + onboardingStatus: newOnboardingStatus, } }); @@ -144,7 +370,7 @@ export class AuthService { } public async listPartners() { - return await prisma.user.findMany({ + const partners = await prisma.user.findMany({ where: { role: 'PARTNER_USER' }, select: { id: true, @@ -154,9 +380,55 @@ export class AuthService { createdAt: true, updatedAt: true, organizationId: true, + partnerGroup: true, + assignedNdaId: true, + assignedMsaId: true, + assignedNda: { + select: { id: true, version: true } + }, + assignedMsa: { + select: { id: true, version: true } + }, + inviteToken: true, }, orderBy: { createdAt: 'desc' }, }); + + const partnersWithSharedAssets = await Promise.all( + partners.map(async (partner) => { + let sharedAssets: any[] = []; + if (partner.organizationId) { + sharedAssets = await prisma.sharedAsset.findMany({ + where: { + organizationId: partner.organizationId, + OR: [ + { userId: null }, + { userId: partner.id } + ] + }, + select: { + assetId: true, + asset: { + select: { + id: true, + title: true, + type: true, + categoryId: true, + subcategory: true, + url: true + } + } + } + }); + } + return { + ...partner, + sharedAssets + }; + }) + ); + + return partnersWithSharedAssets; } public async getUserById(id: string) { @@ -171,6 +443,8 @@ export class AuthService { onboardingStatus: true, createdAt: true, updatedAt: true, + assignedNdaId: true, + assignedMsaId: true, organization: { select: { id: true, @@ -200,6 +474,8 @@ export class AuthService { onboardingStatus: true, createdAt: true, updatedAt: true, + assignedNdaId: true, + assignedMsaId: true, organization: { select: { id: true, diff --git a/Channel-Backend/src/services/legal.service.ts b/Channel-Backend/src/services/legal.service.ts index d9d30fe..f211f8c 100644 --- a/Channel-Backend/src/services/legal.service.ts +++ b/Channel-Backend/src/services/legal.service.ts @@ -21,7 +21,25 @@ export class LegalService { }); } - public async getActiveDocument(type: DocumentType) { + public async getActiveDocument(type: DocumentType, userId?: string) { + if (userId) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { assignedNdaId: true, assignedMsaId: true } + }); + if (user) { + const assignedId = type === 'NDA' ? user.assignedNdaId : user.assignedMsaId; + if (assignedId) { + const doc = await prisma.legalDocument.findUnique({ + where: { id: assignedId } + }); + if (doc) return doc; + } else { + return null; + } + } + } + return await prisma.legalDocument.findFirst({ where: { type, isActive: true }, }); @@ -44,17 +62,21 @@ export class LegalService { } public async checkOnboardingCompletion(userId: string) { - // Check if both NDA and MSA have been accepted + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { assignedNdaId: true, assignedMsaId: true } + }); + const acceptances = await prisma.legalAcceptance.findMany({ where: { userId }, include: { document: true } }); - const hasNDA = acceptances.some(a => a.document.type === 'NDA'); - const hasMSA = acceptances.some(a => a.document.type === 'MSA'); + const hasNDA = !user?.assignedNdaId || acceptances.some(a => a.document.type === 'NDA'); + const hasMSA = !user?.assignedMsaId || acceptances.some(a => a.document.type === 'MSA'); if (hasNDA && hasMSA) { - // Both signed, ready for admin approval. We don't automatically set to APPROVED. + // Both signed or skipped, ready for admin approval. We don't automatically set to APPROVED. // But we can ensure it's PENDING_APPROVAL. await prisma.user.update({ where: { id: userId }, @@ -87,6 +109,8 @@ export class LegalService { id: true, email: true, createdAt: true, + assignedNdaId: true, + assignedMsaId: true, acceptances: { select: { id: true, @@ -102,6 +126,12 @@ export class LegalService { }); } + public async getAllDocuments() { + return await prisma.legalDocument.findMany({ + orderBy: { createdAt: 'desc' } + }); + } + public async approvePartner(userId: string) { return await prisma.user.update({ where: { id: userId }, diff --git a/Channel-Backend/src/services/mail.service.ts b/Channel-Backend/src/services/mail.service.ts new file mode 100644 index 0000000..63d970c --- /dev/null +++ b/Channel-Backend/src/services/mail.service.ts @@ -0,0 +1,69 @@ +import nodemailer from 'nodemailer'; +import { originStorage } from '../utils/origin-storage'; + +export class MailService { + private transporter: nodemailer.Transporter; + + constructor() { + const host = process.env.SMTP_HOST || 'smtp.mailtrap.io'; + const port = parseInt(process.env.SMTP_PORT || '2525', 10); + const user = process.env.SMTP_USER || ''; + const pass = process.env.SMTP_PASS || ''; + + this.transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: user && pass ? { user, pass } : undefined, + tls: { + rejectUnauthorized: false, + }, + }); + } + + public async sendInviteEmail(email: string, inviteToken: string) { + const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173'; + const inviteUrl = `${origin}/invite?token=${inviteToken}`; + + const mailOptions = { + from: process.env.SMTP_FROM || (process.env.SMTP_USER ? `"Tech4Biz Portal" <${process.env.SMTP_USER}>` : '"Tech4Biz Portal" '), + to: email, + subject: 'Tech4Biz Partner Portal Invitation', + html: ` +
+
+

Tech4Biz Partner Portal

+

Authorized Collaborative Access Gateway

+
+ +
+

Hello,

+

You have been invited to register for the Tech4Biz Partner Portal as a collaborative member. This portal allows your organization to view shared resources, asset files, and manage onboarding documentation.

+ +

To set up your account and access the platform, please proceed to the link below to configure your credentials and review the partner documentation:

+ +
+ Access Partner Portal +
+ +

If you are unable to click the button above, please copy and paste the URL below into your web browser:

+

${inviteUrl}

+
+ +
+

© 2026 Tech4Biz Solutions Inc. All rights reserved.

+
+
+ ` + }; + + try { + const info = await this.transporter.sendMail(mailOptions); + console.log(`[SMTP] Invitation email successfully sent to ${email}. Message ID: ${info.messageId}`); + return info; + } catch (err) { + console.error(`[SMTP ERROR] Failed to send invitation email to ${email}:`, err); + throw err; + } + } +} diff --git a/Channel-Backend/src/services/scraper.service.ts b/Channel-Backend/src/services/scraper.service.ts new file mode 100644 index 0000000..7b16432 --- /dev/null +++ b/Channel-Backend/src/services/scraper.service.ts @@ -0,0 +1,229 @@ +import * as cheerio from 'cheerio'; + +interface ScrapeResult { + title: string; + thumbnailUrl: string; + problemStatement: string; + solution: string; +} + +interface PageElement { + type: 'heading' | 'content'; + tagName: string; + text: string; + problemScore: number; + solutionScore: number; +} + +export class ScraperService { + private problemKeywords: Record = { + 'problem statement': 1.0, + 'challenges': 0.9, + 'challenge': 0.9, + 'problem': 0.9, + 'pain point': 0.9, + 'issue': 0.8, + 'background': 0.6, + 'context': 0.5, + 'objective': 0.6, + }; + + private solutionKeywords: Record = { + 'suggested solution': 1.0, + 'solution': 0.9, + 'approach': 0.8, + 'implementation': 0.7, + 'architecture': 0.8, + 'technology': 0.6, + }; + + private stopKeywords: string[] = [ + 'result', + 'conclusion', + 'roi', + 'future', + 'roadmap', + 'benefit', + 'collaboration', + 'support' + ]; + + private getCategoryScore(text: string, keywords: Record): number { + const lowerText = text.toLowerCase(); + let maxScore = 0; + for (const [kw, score] of Object.entries(keywords)) { + if (lowerText.includes(kw)) { + maxScore = Math.max(maxScore, score); + } + } + return maxScore; + } + + public async scrapeCaseStudy(url: string): Promise { + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch page. Status: ${response.status}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + + // Extract Title + let title = $('meta[property="og:title"]').attr('content') || + $('meta[name="twitter:title"]').attr('content') || + $('title').text() || + $('h1').first().text() || + ''; + + title = title.replace(/\s*[-|]\s*Tech4Biz.*$/i, '').trim(); + + // Extract Banner Image/Thumbnail + const thumbnailUrl = $('meta[property="og:image"]').attr('content') || + $('meta[name="twitter:image"]').attr('content') || + ''; + + const elements = $('h1, h2, h3, h4, h5, h6, p, ul, ol'); + const pageElements: PageElement[] = []; + + elements.each((_, el) => { + const $el = $(el); + const tagName = el.name.toLowerCase(); + const text = $el.text().trim(); + if (!text) return; + + // Skip copyright and common footer elements + if (text.toLowerCase().includes('copyright reserved')) return; + + if (tagName.startsWith('h') && text.length < 65) { + const problemScore = this.getCategoryScore(text, this.problemKeywords); + const solutionScore = this.getCategoryScore(text, this.solutionKeywords); + pageElements.push({ + type: 'heading', + tagName, + text, + problemScore, + solutionScore + }); + } else { + let formattedText = ''; + if (tagName === 'ul' || tagName === 'ol') { + const listItems: string[] = []; + $el.find('li').each((_, li) => { + const liText = $(li).text().trim(); + if (liText) { + listItems.push(`• ${liText}`); + } + }); + if (listItems.length > 0) { + formattedText = listItems.join('\n'); + } + } else { + if (text.length > 10) { + formattedText = text; + } + } + + if (formattedText) { + pageElements.push({ + type: 'content', + tagName, + text: formattedText, + problemScore: 0, + solutionScore: 0 + }); + } + } + }); + + // Find the best indices + let bestProblemIdx = -1; + let maxProblemScore = 0.4; + let bestSolutionIdx = -1; + let maxSolutionScore = 0.4; + + pageElements.forEach((el, idx) => { + if (el.type === 'heading') { + if (el.problemScore > maxProblemScore) { + maxProblemScore = el.problemScore; + bestProblemIdx = idx; + } + if (el.solutionScore > maxSolutionScore) { + maxSolutionScore = el.solutionScore; + bestSolutionIdx = idx; + } + } + }); + + // Fallback searches + if (bestProblemIdx === -1) { + bestProblemIdx = pageElements.findIndex(el => + el.type === 'heading' && + (el.text.toLowerCase().includes('background') || el.text.toLowerCase().includes('overview')) + ); + } + + if (bestSolutionIdx === -1) { + bestSolutionIdx = pageElements.findIndex(el => + el.type === 'heading' && + (el.text.toLowerCase().includes('implementation') || el.text.toLowerCase().includes('results') || el.text.toLowerCase().includes('benefits') || el.text.toLowerCase().includes('components')) + ); + } + + const gatherContent = (startIdx: number, otherIdx: number, isProblem: boolean): string => { + if (startIdx === -1) return ''; + + const collected: string[] = []; + let contentCount = 0; + + for (let i = startIdx + 1; i < pageElements.length; i++) { + if (i === otherIdx) break; + + const el = pageElements[i]; + if (el.type === 'heading') { + const lowerText = el.text.toLowerCase(); + + // Stop if we hit the other major category heading + if (isProblem && el.solutionScore >= 0.8) break; + if (!isProblem && el.problemScore >= 0.8) break; + + // Stop if we hit a stop keyword + const matchesStop = this.stopKeywords.some(kw => lowerText.includes(kw)); + if (matchesStop) break; + + // Include subheading formatting + collected.push(`**${el.text}**`); + } else { + // Simple de-duplication for consecutive identical elements + if (collected[collected.length - 1] !== el.text) { + collected.push(el.text); + contentCount++; + } + if (contentCount >= 5) break; + } + } + + return collected.join('\n\n'); + }; + + const problemStatement = gatherContent(bestProblemIdx, bestSolutionIdx, true); + const solution = gatherContent(bestSolutionIdx, bestProblemIdx, false); + + return { + title, + thumbnailUrl, + problemStatement, + solution + }; + } catch (error: any) { + console.error('[SCRAPE ERROR] Failed to scrape case study:', error.message); + throw new Error(`Scraping failed: ${error.message}`); + } + } +} + diff --git a/Channel-Backend/src/utils/origin-storage.ts b/Channel-Backend/src/utils/origin-storage.ts new file mode 100644 index 0000000..fa16598 --- /dev/null +++ b/Channel-Backend/src/utils/origin-storage.ts @@ -0,0 +1,3 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export const originStorage = new AsyncLocalStorage(); diff --git a/Channel-Frontend/index.html b/Channel-Frontend/index.html index 0ff3788..43f8c08 100644 --- a/Channel-Frontend/index.html +++ b/Channel-Frontend/index.html @@ -2,7 +2,7 @@ - + Tech4Biz Client & Admin Portal diff --git a/Channel-Frontend/src/app/layouts/AdminLayout.tsx b/Channel-Frontend/src/app/layouts/AdminLayout.tsx index 9a38e63..b687f2b 100644 --- a/Channel-Frontend/src/app/layouts/AdminLayout.tsx +++ b/Channel-Frontend/src/app/layouts/AdminLayout.tsx @@ -177,11 +177,11 @@ export const AdminLayout: React.FC = () => { {/* Mobile Header */}
-
- +
+ Tech4Biz
- Admin Console + Tech4Biz diff --git a/Channel-Frontend/src/app/router/index.tsx b/Channel-Frontend/src/app/router/index.tsx index afec43c..351c576 100644 --- a/Channel-Frontend/src/app/router/index.tsx +++ b/Channel-Frontend/src/app/router/index.tsx @@ -51,6 +51,11 @@ const ClientAgreementsPage = React.lazy(() => default: m.ClientAgreementsPage, })), ); +const GroupDetailsPage = React.lazy(() => + import("../../pages/admin/GroupDetailsPage").then((m) => ({ + default: m.GroupDetailsPage, + })), +); // Dummy Components for routing const LoadingFallback = () => ( @@ -196,6 +201,14 @@ export const router = createBrowserRouter([ ), }, + { + path: "groups/:groupId", + element: ( + }> + + + ), + }, ], }, ]); diff --git a/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx b/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx index e637c07..c106503 100644 --- a/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx +++ b/Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx @@ -45,6 +45,8 @@ interface DocumentPreviewModalProps { onVerify: (docType: "NDA" | "MSA") => void; onApprovePartner: () => void; isApproving: boolean; + assignedNdaId?: string | null; + assignedMsaId?: string | null; } export const DocumentPreviewModal: React.FC = ({ @@ -58,6 +60,8 @@ export const DocumentPreviewModal: React.FC = ({ onVerify, onApprovePartner, isApproving, + assignedNdaId, + assignedMsaId, }) => { const [currentTab, setCurrentTab] = useState<"NDA" | "MSA">("NDA"); const [zoom, setZoom] = useState(100); @@ -212,7 +216,9 @@ export const DocumentPreviewModal: React.FC = ({ const fileHost = ( import.meta.env.VITE_API_URL || "/api/v1" ).replace("/api/v1", ""); - const isBothVerified = verifiedDocs.nda && verifiedDocs.msa; + const isNdaVerified = !assignedNdaId || verifiedDocs.nda; + const isMsaVerified = !assignedMsaId || verifiedDocs.msa; + const isBothVerified = isNdaVerified && isMsaVerified; const isCurrentVerified = currentTab === "NDA" ? verifiedDocs.nda : verifiedDocs.msa; @@ -312,7 +318,7 @@ export const DocumentPreviewModal: React.FC = ({
{/* Warning Banner if Unsigned */} - {!activeAcceptance && ( + {!activeAcceptance && (currentTab === "NDA" ? assignedNdaId : assignedMsaId) && (

@@ -546,11 +552,11 @@ export const DocumentPreviewModal: React.FC = ({

{activeAcceptance.signatureBase64 ? ( // Render the exact drawn signature as-is — no modification - Client drawn signature ) : ( // Upload-based signing — no drawn image stored @@ -634,11 +640,12 @@ export const DocumentPreviewModal: React.FC = ({

- Document Unavailable + {!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) ? "Agreement Not Required" : "Document Unavailable"}

- The partner has not yet submitted or signed the {currentTab}{" "} - document. + {!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) + ? "This partner onboarding configuration does not require signing this agreement." + : `The partner has not yet submitted or signed the ${currentTab} document.`}

)} @@ -747,14 +754,32 @@ export const DocumentPreviewModal: React.FC = ({ )}
+ {activeAcceptance?.signatureBase64 && ( +
+

+ Captured Signature +

+
+ Signature +
+
+ )} +

- Marking this verified unlocks approval options for the - administrator. + {!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) + ? "This agreement is not required for this partner." + : "Marking this verified unlocks approval options for the administrator."}

@@ -776,15 +802,15 @@ export const DocumentPreviewModal: React.FC = ({ {/* Sticky Footer */}
- {verifiedDocs.nda && verifiedDocs.msa ? ( + {isBothVerified ? ( - Both agreements + Required agreements verified. Access approval unlocked. ) : ( Please - review and verify both the NDA and MSA documents. + review and verify the pending required documents. )}
@@ -795,7 +821,7 @@ export const DocumentPreviewModal: React.FC = ({ + )} +
+ + + {asset.type === 'case_study' && isExpanded && (asset.problemStatement || asset.solution) && ( + + {asset.problemStatement && ( +
+ Problem / Challenge +

+ {asset.problemStatement} +

+
+ )} + {asset.solution && ( +
+ Our Approach +

+ {asset.solution} +

+
+ )} + +
+ )} +
- {asset.type === 'url' ? 'Type' : 'Downloads'} + {asset.type === 'url' || asset.type === 'case_study' ? 'Type' : 'Downloads'} - {asset.type === 'url' ? 'URL Link' : asset.downloadsCount} + {asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
- {asset.type === 'url' ? ( + {asset.type === 'url' || asset.type === 'case_study' ? ( @@ -366,7 +450,7 @@ export const AssetCard: React.FC = ({ )}
- + ); }; diff --git a/Channel-Frontend/src/features/assets/components/AssetDetailsModal.tsx b/Channel-Frontend/src/features/assets/components/AssetDetailsModal.tsx index ed977e1..3fb6ebd 100644 --- a/Channel-Frontend/src/features/assets/components/AssetDetailsModal.tsx +++ b/Channel-Frontend/src/features/assets/components/AssetDetailsModal.tsx @@ -55,6 +55,37 @@ export const AssetDetailsModal: React.FC = ({ )} + {asset.type === 'case_study' && asset.thumbnailUrl && ( +
+

Banner Image

+
+ Case Study Banner +
+
+ )} + + {asset.type === 'case_study' && asset.problemStatement && ( +
+

Problem / Challenge

+

+ {asset.problemStatement} +

+
+ )} + + {asset.type === 'case_study' && asset.solution && ( +
+

Suggested Solution

+

+ {asset.solution} +

+
+ )} +

Category

diff --git a/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx b/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx index 363215b..77df3b0 100644 --- a/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx +++ b/Channel-Frontend/src/features/assets/components/AssetViewerModal.tsx @@ -49,7 +49,12 @@ export const AssetViewerModal: React.FC = ({ const isLocalUrl = (url: string) => { const resolved = getFullAssetUrl(url); - return resolved.includes('localhost') || resolved.includes('127.0.0.1'); + return ( + resolved.includes('localhost') || + resolved.includes('127.0.0.1') || + resolved.includes('ngrok-free.dev') || + resolved.includes('ngrok.io') + ); }; const getGithubRawUrl = (url: string) => { @@ -79,19 +84,60 @@ export const AssetViewerModal: React.FC = ({ return null; }; + const isTextOrCodeAsset = (url: string, type: string) => { + const assetUrl = url.toLowerCase(); + return ( + assetUrl.endsWith('.md') || + assetUrl.endsWith('.txt') || + assetUrl.endsWith('.json') || + assetUrl.endsWith('.js') || + assetUrl.endsWith('.ts') || + assetUrl.endsWith('.tsx') || + assetUrl.endsWith('.jsx') || + assetUrl.endsWith('.py') || + assetUrl.endsWith('.yaml') || + assetUrl.endsWith('.yml') || + assetUrl.endsWith('.css') || + assetUrl.endsWith('.html') || + assetUrl.endsWith('.sh') || + type.includes('text') || + type.includes('markdown') || + type.includes('json') || + type.includes('javascript') + ); + }; + + const isAudioAsset = (url: string, type: string) => { + const assetUrl = url.toLowerCase(); + return ( + assetUrl.endsWith('.mp3') || + assetUrl.endsWith('.wav') || + assetUrl.endsWith('.ogg') || + assetUrl.endsWith('.m4a') || + assetUrl.endsWith('.webm') || + type.includes('audio/') + ); + }; + + const isVideoAsset = (url: string, type: string) => { + const assetUrl = url.toLowerCase(); + return ( + assetUrl.endsWith('.mp4') || + assetUrl.endsWith('.webm') || + assetUrl.endsWith('.ogg') || + assetUrl.endsWith('.mov') || + type.includes('video/') + ); + }; + useEffect(() => { if (isOpen && asset) { setTextPreviewContent(''); setIsLoadingText(false); - const assetUrl = asset.url.toLowerCase(); - if ( asset.type !== 'url' && - (assetUrl.endsWith('.md') || - assetUrl.endsWith('.txt') || - asset.type.includes('text') || - asset.type.includes('markdown')) + isTextOrCodeAsset(asset.url, asset.type) ) { setIsLoadingText(true); axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'text' }) @@ -172,7 +218,7 @@ export const AssetViewerModal: React.FC = ({
} size="full" - className={isMaximized ? '!max-w-[96vw] !max-h-[92vh] !h-[92vh] !mt-4' : '!max-w-5xl !w-full'} + className={isMaximized ? '!max-w-[96vw] !max-h-[92vh] !h-[92vh] !mt-4' : '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'} footer={ <>
- ) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.type.includes('markdown')) ? ( + ) : isTextOrCodeAsset(asset.url, asset.type) ? (
@@ -283,12 +329,12 @@ export const AssetViewerModal: React.FC = ({

{asset.title}

-

Plain Text / Markdown Format

+

Plain Text / Source Code Format

{asset.url.toLowerCase().endsWith('.md') || asset.type.includes('markdown') ? ( ) : ( -
+                      
                         {textPreviewContent}
                       
)} @@ -296,6 +342,36 @@ export const AssetViewerModal: React.FC = ({ )}
+ ) : isAudioAsset(asset.url, asset.type) ? ( +
+
+ 🎵 +
+
+

Audio Player

+

You can listen to this audio asset directly in the browser.

+
+
+ ) : isVideoAsset(asset.url, asset.type) ? ( +
+
+ + Video Player +
+
+
+
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? (
@@ -313,7 +389,7 @@ export const AssetViewerModal: React.FC = ({ This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).

- Note: Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally. + Note: Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost or ngrok tunnel), external Microsoft services cannot fetch your local files. Please download this asset using the button below to view it locally.

diff --git a/Channel-Frontend/src/features/assets/components/ManageGroupsModal.tsx b/Channel-Frontend/src/features/assets/components/ManageGroupsModal.tsx new file mode 100644 index 0000000..f52ee0d --- /dev/null +++ b/Channel-Frontend/src/features/assets/components/ManageGroupsModal.tsx @@ -0,0 +1,293 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { Asset } from '../../../types/assets'; +import type { AssetGroup } from '../../../services/assets-api'; +import Modal from '../../../components/ui/Modal'; +import Button from '../../../components/ui/Button'; +import { Folder, Plus, Trash2, Search, CheckCircle, ExternalLink } from 'lucide-react'; +import { createAssetGroup, deleteAssetGroup } from '../../../services/assets-api'; +import { useToast } from '../../../hooks/use-toast'; + +interface ManageGroupsModalProps { + isOpen: boolean; + onClose: () => void; + assets: Asset[]; + groups: AssetGroup[]; + onRefresh: () => void; +} + +export const ManageGroupsModal: React.FC = ({ + isOpen, + onClose, + assets, + groups, + onRefresh, +}) => { + const { success, error } = useToast(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedAssetIds, setSelectedAssetIds] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const filteredAssets = assets.filter(asset => + asset.title.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleToggleAsset = (id: string) => { + setSelectedAssetIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + ); + }; + + const handleSelectAll = () => { + if (selectedAssetIds.length === assets.length) { + setSelectedAssetIds([]); + } else { + setSelectedAssetIds(assets.map(a => a.id)); + } + }; + + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + setIsSubmitting(true); + try { + await createAssetGroup({ + name: name.trim(), + description: description.trim() || undefined, + assetIds: selectedAssetIds, + }); + success('Asset group created', `"${name}" group was successfully registered.`); + setName(''); + setDescription(''); + setSelectedAssetIds([]); + onRefresh(); + } catch (err: any) { + console.error(err); + error('Failed to create group', err.response?.data?.error || 'Something went wrong.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteGroup = async (id: string, groupName: string) => { + if (!window.confirm(`Are you sure you want to permanently delete the asset bundle "${groupName}"?`)) { + return; + } + try { + await deleteAssetGroup(id); + success('Asset group deleted', `"${groupName}" group has been deleted.`); + onRefresh(); + } catch (err: any) { + console.error(err); + error('Failed to delete group', err.response?.data?.error || 'Something went wrong.'); + } + }; + + const getFileIcon = (type: string, title: string) => { + const lowerType = type.toLowerCase(); + const lowerTitle = title.toLowerCase(); + if (lowerType.includes('pdf') || lowerTitle.endsWith('.pdf')) { + return ( +
+ PDF +
+ ); + } + if (lowerType.includes('word') || lowerTitle.endsWith('.docx') || lowerTitle.endsWith('.doc')) { + return ( +
+ DOC +
+ ); + } + if (lowerType.includes('presentation') || lowerTitle.endsWith('.pptx') || lowerTitle.endsWith('.ppt')) { + return ( +
+ PPT +
+ ); + } + return ( +
+ URL +
+ ); + }; + + return ( + +
+ {/* Left Side: Existing Groups */} +
+

+ + Active Asset Groups ({groups.length}) +

+ +
+ {groups.length === 0 ? ( +
+ +

No Asset Groups Created

+

Use the panel on the right to bundle your first set of resources.

+
+ ) : ( + groups.map(group => ( +
+
+

{group.name}

+ {group.description && ( +

{group.description}

+ )} +
+ + + View Page + + + {group.assets.length} Assets + +
+
+ +
+ )) + )} +
+
+ + {/* Right Side: Create Group Form */} +
+

+ + Create New Bundle +

+ +
+
+ + setName(e.target.value)} + placeholder="e.g. Sales Onboarding Pack" + required + className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold font-sans" + /> +
+ +
+ +