import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { S3 } from 'aws-sdk'; import axios from 'axios'; import { EntityManager } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import { ENTITYTYPE_MEDIA, STATUS_PENDING, } from '../../../constant/global.constant'; import { MediaData } from '../entity/media-data.entity'; import { MediaDataRepository } from '../repository/media-data.repository'; import { EntityServiceImpl } from './entity-service-impl.service'; @Injectable() export class MediaDataService extends EntityServiceImpl { constructor( private readonly configService: ConfigService, private readonly mediaRepository: MediaDataRepository, private entityManager: EntityManager, ) { super(); } s3AccessKeyID = this.configService.get('AWS.S3.AWS_ACCESS_KEY_ID'); s3AccessKeySecret = this.configService.get('AWS.S3.AWS_SECRET_KEY'); s3Region = this.configService.get('AWS.S3.AWS_REGION'); bucketName = this.configService.get('AWS.S3.BUCKET_NAME'); s3 = new S3({ accessKeyId: this.s3AccessKeyID, secretAccessKey: this.s3AccessKeySecret, region: this.s3Region, signatureVersion: 'v4', }); async generateMediaUploadDetails( fileName: string, mappedAttributeKey: string, loggedInUser, mappedEntityType?: string, mappedEntityId?: number, parentId?: number, parentType?: string, ) { if (!fileName || !mappedAttributeKey) { return null; } // Validate AWS configuration if (!this.s3AccessKeyID || !this.s3AccessKeySecret) { throw new Error( 'AWS credentials not configured. Please check AWS_ACCESS_KEY_ID and AWS_SECRET_KEY in configuration.', ); } if (!this.bucketName) { throw new Error( 'S3 bucket name not configured. Please check BUCKET_NAME in configuration.', ); } if (!this.s3Region) { throw new Error( 'AWS region not configured. Please check AWS_REGION in configuration.', ); } const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; const id = uuidv4(); const s3Path = (await this.buildUploadPathGeneric( mappedEntityType || '', loggedInUser, mappedEntityId, parentId, parentType, )) || `uploads`; const s3Key = `${s3Path}/${id}.${ext}`; try { const uploadUrl = await this.s3.getSignedUrlPromise('putObject', { Bucket: this.bucketName, Key: s3Key, Expires: 60 * 5, // URL valid for 5 mins ContentType: this.getContentType(ext), }); const mediaData = new MediaData(); mediaData.file_name = fileName; mediaData.mapped_attribute_key = mappedAttributeKey; mediaData.status = STATUS_PENDING; if (mappedEntityType) { mediaData.mapped_entity_type = mappedEntityType; } if (mappedEntityId) { mediaData.mapped_entity_id = mappedEntityId; } mediaData.media_url = s3Key; //INSERT RECORD IN DOC TABLE const savedEntity = await super.createEntity(mediaData, loggedInUser); if (savedEntity) { return { id: savedEntity.id, path: s3Key, uploadUrl }; } return null; } catch (error) { console.error('Error generating upload URL:', error); console.error('Error details:', { message: error.message, code: error.code, statusCode: error.statusCode, bucketName: this.bucketName, region: this.s3Region, hasCredentials: !!(this.s3AccessKeyID && this.s3AccessKeySecret), }); throw new Error(`Failed to generate upload URL: ${error.message}`); } } async findByAttributeKeyAndMappedEntityIdAndMappedEntityType( attributeKey: string, mappedEntityId: number, mappedEntityType: string, ) { return await this.mediaRepository.findByAttributeKeyAndMappedEntityIdAndMappedEntityType( attributeKey, mappedEntityId, mappedEntityType, ); } async findByMappedEntityIdAndMappedEntityType( mappedEntityId: number, mappedEntityType: string, ) { return await this.mediaRepository.findByMappedEntityIdAndMappedEntityType( mappedEntityId, mappedEntityType, ); } async deleteByAttributeKeyAndMappedEntityIdAndMappedEntityType( attributeKey: string, mappedEntityId: number, mappedEntityType: string, ) { return await this.mediaRepository.deleteByAttributeKeyAndMappedEntityIdAndMappedEntityType( attributeKey, mappedEntityId, mappedEntityType, ); } private getContentType(ext: string): string { const map = { pdf: 'application/pdf', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', txt: 'text/plain', html: 'text/html', htm: 'text/html', doc: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }; return map[ext] || 'application/octet-stream'; } // New method to get the signed URL for the media (download link) async getMediaDownloadUrl(id: number, loggedInUser, expiresIn?: number) { try { // Validate AWS configuration if (!this.s3AccessKeyID || !this.s3AccessKeySecret) { throw new Error( 'AWS credentials not configured. Please check AWS_ACCESS_KEY_ID and AWS_SECRET_KEY in configuration.', ); } if (!this.bucketName) { throw new Error( 'S3 bucket name not configured. Please check BUCKET_NAME in configuration.', ); } if (!this.s3Region) { throw new Error( 'AWS region not configured. Please check AWS_REGION in configuration.', ); } const entityData = await this.getEntityData( ENTITYTYPE_MEDIA, id, loggedInUser, ); const mediaData = entityData as MediaData; if (!mediaData || !mediaData.media_url) { throw new Error(`Media data not found or invalid for id: ${id}`); } let fileSize: number | undefined; if (this.bucketName) { try { const head = await this.s3 .headObject({ Bucket: this.bucketName, Key: mediaData.media_url, }) .promise(); fileSize = head.ContentLength; } catch (headError) { console.error( `Error checking file in S3 bucket '${this.bucketName}' with key '${mediaData.media_url}':`, headError.message, ); // Continue even if headObject fails - the file might still be accessible } } const signedUrl = await this.s3.getSignedUrlPromise('getObject', { Bucket: this.bucketName, Key: mediaData.media_url, Expires: Number(expiresIn) || 60 * 5, ResponseContentDisposition: 'inline', }); return { signedUrl, fileName: mediaData.file_name, id: mediaData.id, size: fileSize, review_info: mediaData.review_info, }; } catch (error) { console.error('Error generating signed URL for media:', error); console.error('Error details:', { message: error.message, code: error.code, statusCode: error.statusCode, bucketName: this.bucketName, region: this.s3Region, hasCredentials: !!(this.s3AccessKeyID && this.s3AccessKeySecret), }); throw new Error(`Failed to generate signed URL: ${error.message}`); } } async getMediaInlineUrl(id: number, loggedInUser, expiresIn?: number) { try { // Validate AWS configuration if (!this.s3AccessKeyID || !this.s3AccessKeySecret) { throw new Error( 'AWS credentials not configured. Please check AWS_ACCESS_KEY_ID and AWS_SECRET_KEY in configuration.', ); } if (!this.bucketName) { throw new Error( 'S3 bucket name not configured. Please check BUCKET_NAME in configuration.', ); } if (!this.s3Region) { throw new Error( 'AWS region not configured. Please check AWS_REGION in configuration.', ); } const entityData = await this.getEntityData( ENTITYTYPE_MEDIA, id, loggedInUser, ); const mediaData = entityData as MediaData; let fileSize: number | undefined; if (!mediaData || !mediaData.media_url) { throw new Error(`Media data not found or invalid for id: ${id}`); } const ext = mediaData.file_name.split('.').pop()?.toLowerCase() || ''; if (this.bucketName) { try { const head = await this.s3 .headObject({ Bucket: this.bucketName, Key: mediaData.media_url, }) .promise(); fileSize = head.ContentLength; } catch (headError) { console.error( `Error checking file in S3 bucket '${this.bucketName}' with key '${mediaData.media_url}':`, headError.message, ); // Continue even if headObject fails - the file might still be accessible } } const signedUrl = await this.s3.getSignedUrlPromise('getObject', { Bucket: this.bucketName, Key: mediaData.media_url, Expires: Number(expiresIn) || 60 * 5, ResponseContentDisposition: 'inline', ResponseContentType: this.getContentType(ext), }); let previewUrl = signedUrl; // if (ext === 'doc' || ext === 'docx') { // previewUrl = // `https://view.officeapps.live.com/op/embed.aspx?src=` + // encodeURIComponent(signedUrl); // } return { signedUrl: previewUrl, fileName: mediaData.file_name, id: mediaData.id, size: fileSize, uploadedDate: mediaData.created_date, }; } catch (error) { console.error('Error generating inline URL:', error); console.error('Error details:', { message: error.message, code: error.code, statusCode: error.statusCode, bucketName: this.bucketName, region: this.s3Region, hasCredentials: !!(this.s3AccessKeyID && this.s3AccessKeySecret), }); throw new Error(`Failed to generate inline URL: ${error.message}`); } } public async buildUploadPathGenericdd( mappedEntityType: string, loggedInUser, mappedEntityId?: number, parentId?: number, parentType?: string, ) { // 1️⃣ Fetch entity metadata const entityMaster = await this.entityMasterService.getEntityData( mappedEntityType, loggedInUser, ); if (!entityMaster) { throw new Error(`Entity master not found for ${mappedEntityType}`); } let pathTemplate = entityMaster.doc_upload_path ?? ''; const entityOverwrite = entityMaster.overwrite_path ?? 0; if (!pathTemplate) { throw new Error(`doc_upload_path not defined for ${mappedEntityType}`); } // 2️⃣ Prepare replacement map const replacements: Record = {}; // --- ORG --- const organizationRepo = this.reflectionHelper.getRepoService('OrganizationData'); let organizationData = await organizationRepo.findOne({ where: { id: loggedInUser.organization_id, }, }); replacements['org_code'] = organizationData.code; // --- LEVEL CODE --- // Priority: If parent is provided, use parent for level_code; otherwise use normal logic let levelEntityId: number; let levelEntityType: string; if (parentId && parentType) { // When parent is provided, parent becomes the level_code levelEntityId = parentId; levelEntityType = parentType; } else { // Normal logic: determine which entity to use for level_code levelEntityId = entityOverwrite ? mappedEntityId : loggedInUser.level_id; levelEntityType = entityOverwrite ? mappedEntityType : loggedInUser.level_type; } // Get entity data for level_code const getEntityData = await this.entityMasterService.getEntityData( levelEntityType, loggedInUser, ); if (!getEntityData) return null; const levelEntityDataResult = await this.entityManager.query( `SELECT * FROM ${getEntityData?.db_table_name} WHERE id = $1`, [levelEntityId], ); const levelEntityData = levelEntityDataResult[0] || null; if (levelEntityData?.code) { replacements['level_code'] = levelEntityData.code; // universal alias } // --- MAPPED ENTITY CODE (when parent is provided) --- // When parent is provided, the mapped entity becomes an additional level if (parentId && parentType && mappedEntityId && mappedEntityType) { const mappedEntityKey = `${mappedEntityType.toLowerCase()}_code`; const mappedEntityData = await super.getEntityData( mappedEntityType, mappedEntityId, loggedInUser, ); if (mappedEntityData?.code) { replacements[mappedEntityKey] = mappedEntityData.code; // Modify path template to include mapped entity // Example: ${org_code}/${level_code} becomes ${org_code}/${level_code}/${tem_code} const mappedVariable = `\${${mappedEntityKey}}`; if (!pathTemplate.includes(mappedVariable)) { pathTemplate = pathTemplate + `/${mappedVariable}`; } } } // --- TEMPLATE CODE (if parent is provided) --- // Special handling for template_code when parent_id and parent_type are provided if (parentId && parentType) { // const templateEntityData = await super.getEntityData( // parentType, // parentId, // loggedInUser, // ); // Get entity data for level_code const getEntityData = await this.entityMasterService.getEntityData( mappedEntityType, loggedInUser, ); if (!getEntityData) return null; const levelEntityDataResult = await this.entityManager.query( `SELECT * FROM ${getEntityData?.db_table_name} WHERE id = $1`, [mappedEntityId], ); if ( levelEntityDataResult?.[0].code && parentType != 'SCH' && loggedInUser.level_type != 'SCH' ) { replacements['template_code'] = levelEntityDataResult[0].code; // If we have parent template, modify the path to include it pathTemplate = '${org_code}/template/${template_code}'; } else { const getEntityData = await this.entityMasterService.getEntityData( mappedEntityType, loggedInUser, ); if (!getEntityData) return null; const levelEntityDataResult = await this.entityManager.query( `SELECT * FROM ${getEntityData?.db_table_name} WHERE id = $1`, [mappedEntityId], ); replacements['template_code'] = levelEntityDataResult?.[0].code; pathTemplate = '${org_code}/${level_code}/template/${template_code}'; } } // --- Dynamic variables inside path --- const matches = pathTemplate.match(/\$\{(\w+)\}/g) || []; for (const variable of matches) { const key = variable.replace(/\$\{|\}/g, ''); if (replacements[key]) continue; // already resolved if (key === 'org_code') continue; // already set if (key === 'level_code') continue; // already set above if (key === 'template_code') continue; // handled above if parent is provided if (key.endsWith('_code')) { // derive entity type dynamically const entityType = key.replace('_code', '').toUpperCase(); let entityId: number | undefined; // Special handling for template_code - use parent if available, otherwise mapped entity if (key === 'template_code') { if (parentType?.toUpperCase() === 'TEMPLATE' && parentId) { entityId = parentId; } else if (mappedEntityType === 'TEMPLATE') { entityId = mappedEntityId; } } else { // For other _code variables, check if parent matches the entity type if (parentType?.toUpperCase() === entityType && parentId) { entityId = parentId; } else if (mappedEntityType === entityType) { entityId = mappedEntityId; } } // Generic lookup if (entityId) { const entityData = await super.getEntityData( entityType, entityId, loggedInUser, ); replacements[key] = entityData?.code ?? null; } } } // --- ORG special case --- // If ORG level & overwrite = 0 → use org_code, otherwise use the mapped entity's code if (loggedInUser.level_type === 'ORG' && entityOverwrite === 0) { // When overwrite is 0 and user is ORG level, but we want to use mapped entity // Don't set level_code to null - it should already be set above based on entityOverwrite logic } // 3️⃣ Final substitution and cleanup if (pathTemplate) { const finalPath = this.resolveUploadPath(pathTemplate, replacements); return finalPath; } return null; } public async buildUploadPathGeneric( mappedEntityType: string, loggedInUser, mappedEntityId?: number, parentId?: number, parentType?: string, ) { //APPCODE let appCode = ''; let org_id = loggedInUser.organization_id; let level_type = loggedInUser.level_type; let level_id = loggedInUser.level_type == 'ORG' && mappedEntityType == 'SCH' ? mappedEntityId : loggedInUser.level_id; let organizationData = {}; let levelData = {}; let mappedEntityData = {}; if (loggedInUser && loggedInUser.appcode) { appCode = loggedInUser.appcode; } if (appCode && appCode != 'ADM') { const baseUrl = this.configService.get('REDIRECT_BE_URL'); // Prepare the query string const queryParams = new URLSearchParams({ loggedInUser: JSON.stringify(loggedInUser), }).toString(); organizationData = await axios .get( `${baseUrl}/organization/public/${loggedInUser.organization_id}?${queryParams}`, ) .then((res) => res.data) .catch((err) => { console.error('Error fetching organization data:', err.message); return null; }); levelData = await axios .get(`${baseUrl}/school/public/?${queryParams}`) .then((res) => res.data) .catch((err) => { console.error('Error fetching school data:', err.message); return null; }); } else { const organizationProfileRepo = this.reflectionHelper.getRepoService( 'OrganizationProfile', ); organizationData = await organizationProfileRepo.find({ where: { id: loggedInUser.organization_id, }, }); const schoolProfileRepo = this.reflectionHelper.getRepoService('School'); levelData = await schoolProfileRepo.find({ where: { id: loggedInUser.level_id, }, }); } console.log(levelData); // 1️⃣ Fetch entity metadata // const uploadEntity = await this.entityMasterService.getEntityData( // mappedEntityType, // loggedInUser, // ); // if (!uploadEntity) { // throw new Error(`Entity master not found for ${mappedEntityType}`); // } let subfolder = mappedEntityType == level_type ? 'data' : parentType ? `${parentType}/${parentId}/${mappedEntityType}` : mappedEntityType; let path = '${org_code}/${level_code}/' + (subfolder !== 'data' ? `${subfolder}/${mappedEntityId}` : `${subfolder}`); // 2️⃣ Prepare replacement map const replacements: Record = {}; replacements['org_code'] = organizationData[0]?.code; replacements['level_code'] = levelData[0]?.code; // universal alias // 3️⃣ Final substitution and cleanup if (path) { const finalPath = this.resolveUploadPath(path, replacements); return finalPath; } return null; } /** * Replace placeholders with actual values, remove missing variables */ private resolveUploadPath( template: string, replacements: Record, ) { let path = template; path = path.replace(/\$\{(\w+)\}/g, (_, key) => replacements[key] || ''); return path.replace(/\/+/g, '/').replace(/\/$/, ''); } /** * Helper method to build upload path for different scenarios * * @param mappedEntityType - The main entity type (e.g., 'LEAD', 'SCH') * @param loggedInUser - Current user context * @param mappedEntityId - ID of the main entity * @param parentId - Optional parent entity ID (e.g., template ID) * @param parentType - Optional parent entity type (e.g., 'TEMPLATE') * @returns Promise - The resolved upload path * * Examples: * - buildUploadPath('LEAD', user, 123) -> "ORG1/template/SCH1" * - buildUploadPath('LEAD', user, 123, 456, 'TEMPLATE') -> "ORG1/template/SCH1/TEMPLATE1" */ async buildUploadPath( mappedEntityType: string, loggedInUser: any, mappedEntityId?: number, parentId?: number, parentType?: string, ): Promise { return this.buildUploadPathGeneric( mappedEntityType, loggedInUser, mappedEntityId, parentId, parentType, ); } async updateMediaReviewStatus(id: number, entityData: any, loggedInUser) { await super.updateEntity( { id, entity_type: 'MDA', review_info: entityData, } as any, loggedInUser, ); } }