mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-08-25 05:42:27 +00:00
feat: working on signed url for private files
This commit is contained in:
2975
package-lock.json
generated
2975
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -28,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@abdalhamid/hello": "^2.0.0",
|
||||
"@hamid/hello": "file:../libraries/test-package",
|
||||
"@keyv/redis": "^4.0.2",
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/axios": "^3.1.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
@ -45,6 +46,7 @@
|
||||
"amqp-connection-manager": "^4.1.14",
|
||||
"amqplib": "^0.10.4",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cacheable": "^1.8.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"google-libphonenumber": "^3.2.39",
|
||||
|
@ -7,6 +7,7 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { addTransactionalDataSource } from 'typeorm-transactional';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CacheModule } from './common/modules/cache/cache.module';
|
||||
import { LookupModule } from './common/modules/lookup/lookup.module';
|
||||
import { OtpModule } from './common/modules/otp/otp.module';
|
||||
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
|
||||
@ -45,6 +46,8 @@ import { TaskModule } from './task/task.module';
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
I18nModule.forRoot(buildI18nOptions()),
|
||||
CacheModule,
|
||||
|
||||
// App modules
|
||||
AuthModule,
|
||||
CustomerModule,
|
||||
|
17
src/common/modules/cache/cache.module.ts
vendored
Normal file
17
src/common/modules/cache/cache.module.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { buildKeyvOptions } from '~/core/module-options';
|
||||
import { CacheService } from './services';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'CACHE_INSTANCE',
|
||||
useFactory: (config: ConfigService) => buildKeyvOptions(config),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
CacheService,
|
||||
],
|
||||
exports: ['CACHE_INSTANCE', CacheService],
|
||||
})
|
||||
export class CacheModule {}
|
19
src/common/modules/cache/services/cache.services.ts
vendored
Normal file
19
src/common/modules/cache/services/cache.services.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Cacheable } from 'cacheable';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
constructor(@Inject('CACHE_INSTANCE') private readonly cache: Cacheable) {}
|
||||
|
||||
get<T>(key: string): Promise<T | undefined> {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number | string): Promise<void> {
|
||||
await this.cache.set(key, value, ttl);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.cache.delete(key);
|
||||
}
|
||||
}
|
1
src/common/modules/cache/services/index.ts
vendored
Normal file
1
src/common/modules/cache/services/index.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export * from './cache.services';
|
@ -1,11 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DocumentType } from '~/document/enums';
|
||||
import { DocumentService } from '~/document/services';
|
||||
import { DocumentService, OciService } from '~/document/services';
|
||||
|
||||
@Injectable()
|
||||
export class LookupService {
|
||||
constructor(private readonly documentService: DocumentService) {}
|
||||
findDefaultAvatar() {
|
||||
return this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
|
||||
constructor(private readonly documentService: DocumentService, private readonly ociService: OciService) {}
|
||||
async findDefaultAvatar() {
|
||||
const documents = await this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
|
||||
|
||||
await Promise.all(
|
||||
documents.map(async (document) => {
|
||||
document.url = await this.ociService.generatePreSignedUrl(document);
|
||||
}),
|
||||
);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
async findDefaultTasksLogo() {
|
||||
const documents = await this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_TASKS_LOGO });
|
||||
|
||||
await Promise.all(
|
||||
documents.map(async (document) => {
|
||||
document.url = await this.ociService.generatePreSignedUrl(document);
|
||||
}),
|
||||
);
|
||||
|
||||
return documents;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from '././keyv-options';
|
||||
export * from './config-options';
|
||||
export * from './typeorm-options';
|
||||
export * from './logger-options';
|
||||
export * from './typeorm-options';
|
||||
|
8
src/core/module-options/keyv-options.ts
Normal file
8
src/core/module-options/keyv-options.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cacheable } from 'cacheable';
|
||||
|
||||
export function buildKeyvOptions(config: ConfigService) {
|
||||
const secondary = new KeyvRedis(config.get('REDIS_URL'));
|
||||
return new Cacheable({ secondary, ttl: config.get('REDIS_TTL') });
|
||||
}
|
@ -4,4 +4,7 @@ export const BUCKETS: Record<DocumentType, string> = {
|
||||
[DocumentType.PROFILE_PICTURE]: 'profile-pictures',
|
||||
[DocumentType.PASSPORT]: 'passports',
|
||||
[DocumentType.DEFAULT_AVATAR]: 'avatars',
|
||||
[DocumentType.DEFAULT_TASKS_LOGO]: 'tasks-logo',
|
||||
[DocumentType.CUSTOM_AVATAR]: 'avatars',
|
||||
[DocumentType.CUSTOM_TASKS_LOGO]: 'tasks-logo',
|
||||
};
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CacheModule } from '~/common/modules/cache/cache.module';
|
||||
import { DocumentController } from './controllers';
|
||||
import { Document } from './entities';
|
||||
import { DocumentRepository } from './repositories';
|
||||
import { DocumentService, OciService } from './services';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Document])],
|
||||
imports: [TypeOrmModule.forFeature([Document]), CacheModule],
|
||||
controllers: [DocumentController],
|
||||
providers: [DocumentService, OciService, DocumentRepository],
|
||||
exports: [DocumentService],
|
||||
exports: [DocumentService, OciService],
|
||||
})
|
||||
export class DocumentModule {}
|
||||
|
@ -15,6 +15,9 @@ export class DocumentMetaResponseDto {
|
||||
@ApiProperty()
|
||||
documentType!: DocumentType;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
url!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: Date;
|
||||
|
||||
@ -26,6 +29,7 @@ export class DocumentMetaResponseDto {
|
||||
this.name = document.name;
|
||||
this.extension = document.extension;
|
||||
this.documentType = document.documentType;
|
||||
this.url = document.url || null;
|
||||
this.createdAt = document.createdAt;
|
||||
this.updatedAt = document.updatedAt;
|
||||
}
|
||||
|
@ -42,4 +42,7 @@ export class Document {
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date;
|
||||
|
||||
// virtual field
|
||||
url?: string;
|
||||
}
|
||||
|
@ -2,4 +2,7 @@ export enum DocumentType {
|
||||
PROFILE_PICTURE = 'PROFILE_PICTURE',
|
||||
PASSPORT = 'PASSPORT',
|
||||
DEFAULT_AVATAR = 'DEFAULT_AVATAR',
|
||||
DEFAULT_TASKS_LOGO = 'DEFAULT_TASKS_LOGO',
|
||||
CUSTOM_AVATAR = 'CUSTOM_AVATAR',
|
||||
CUSTOM_TASKS_LOGO = 'CUSTOM_TASKS_LOGO',
|
||||
}
|
||||
|
@ -1,12 +1,16 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import moment from 'moment';
|
||||
import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common';
|
||||
import { ObjectStorageClient } from 'oci-objectstorage';
|
||||
import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model';
|
||||
import path from 'path';
|
||||
import { CacheService } from '~/common/modules/cache/services';
|
||||
import { BUCKETS } from '../constants';
|
||||
import { UploadDocumentRequestDto } from '../dtos/request';
|
||||
import { UploadResponseDto } from '../dtos/response';
|
||||
import { Document } from '../entities';
|
||||
import { generateNewFileName } from '../utils';
|
||||
|
||||
@Injectable()
|
||||
@ -20,7 +24,7 @@ export class OciService {
|
||||
private readonly namespace: string = this.configService.getOrThrow('OCI_NAMESPACE');
|
||||
private readonly region: string = this.configService.getOrThrow('OCI_REGION');
|
||||
private readonly logger = new Logger(OciService.name);
|
||||
constructor(private configService: ConfigService) {
|
||||
constructor(private configService: ConfigService, private readonly cacheService: CacheService) {
|
||||
this.ociClient = new ObjectStorageClient({
|
||||
authenticationDetailsProvider: new SimpleAuthenticationDetailsProvider(
|
||||
this.tenancyId,
|
||||
@ -60,4 +64,39 @@ export class OciService {
|
||||
documentType,
|
||||
});
|
||||
}
|
||||
|
||||
async generatePreSignedUrl(document?: Document): Promise<string | any> {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
const cachedUrl = await this.cacheService.get<string>(document.id);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
const bucketName = BUCKETS[document.documentType];
|
||||
const objectName = document.name;
|
||||
const expiration = moment().add(2, 'hours').toDate();
|
||||
|
||||
try {
|
||||
this.logger.debug(`Generating pre-signed url for object ${objectName} in bucket ${bucketName}`);
|
||||
const res = await this.ociClient.createPreauthenticatedRequest({
|
||||
namespaceName: this.namespace,
|
||||
bucketName,
|
||||
createPreauthenticatedRequestDetails: {
|
||||
name: objectName,
|
||||
accessType: CreatePreauthenticatedRequestDetails.AccessType.AnyObjectRead,
|
||||
timeExpires: expiration,
|
||||
objectName,
|
||||
},
|
||||
retryConfiguration: { terminationStrategy: { shouldTerminate: () => true } },
|
||||
});
|
||||
|
||||
this.cacheService.set(document.id, res.preauthenticatedRequest.fullPath + objectName, '1h');
|
||||
return res.preauthenticatedRequest.fullPath + objectName;
|
||||
} catch (error) {
|
||||
this.logger.error('Error generating pre-signed url', JSON.stringify(error));
|
||||
return document.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
||||
import { Junior } from '~/junior/entities';
|
||||
import { Relationship } from '~/junior/enums';
|
||||
|
||||
@ -12,13 +13,15 @@ export class JuniorResponseDto {
|
||||
@ApiProperty({ example: 'relationship' })
|
||||
relationship!: Relationship;
|
||||
|
||||
@ApiProperty({ example: 'profilePictureId' })
|
||||
profilePictureId: string | null;
|
||||
@ApiProperty()
|
||||
profilePicture!: DocumentMetaResponseDto | null;
|
||||
|
||||
constructor(junior: Junior) {
|
||||
this.id = junior.id;
|
||||
this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`;
|
||||
this.relationship = junior.relationship;
|
||||
this.profilePictureId = junior.customer.user.profilePictureId;
|
||||
this.profilePicture = junior.customer.user.profilePicture
|
||||
? new DocumentMetaResponseDto(junior.customer.user.profilePicture)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { Roles } from '~/auth/enums';
|
||||
import { IJwtPayload } from '~/auth/interfaces';
|
||||
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
|
||||
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
||||
import { ApiDataPageResponse } from '~/core/decorators';
|
||||
import { CustomParseUUIDPipe } from '~/core/pipes';
|
||||
import { ResponseFactory } from '~/core/utils';
|
||||
import { CreateTaskRequestDto, TaskSubmissionRequestDto } from '../dtos/request';
|
||||
@ -27,6 +28,7 @@ export class TaskController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@ApiDataPageResponse(TaskResponseDto)
|
||||
async findTasks(@Query() query: TasksFilterOptions, @AuthenticatedUser() user: IJwtPayload) {
|
||||
const [tasks, itemCount] = await this.taskService.findTasks(user, query);
|
||||
return ResponseFactory.dataPage(
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
||||
import { JuniorResponseDto } from '~/junior/dtos/response';
|
||||
import { Task } from '~/task/entities';
|
||||
import { TaskSubmission } from '~/task/entities/task-submissions.entity';
|
||||
@ -28,6 +29,9 @@ export class TaskResponseDto {
|
||||
@ApiProperty()
|
||||
junior!: JuniorResponseDto;
|
||||
|
||||
@ApiProperty()
|
||||
image!: DocumentMetaResponseDto;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: Date;
|
||||
|
||||
@ -42,6 +46,7 @@ export class TaskResponseDto {
|
||||
this.dueDate = task.dueDate;
|
||||
this.rewardAmount = task.rewardAmount;
|
||||
this.submission = task.submission;
|
||||
this.image = new DocumentMetaResponseDto(task.image);
|
||||
this.junior = new JuniorResponseDto(task.assignedTo);
|
||||
this.createdAt = task.createdAt;
|
||||
this.updatedAt = task.updatedAt;
|
||||
|
@ -32,7 +32,14 @@ export class TaskRepository {
|
||||
findTask(where: FindOptionsWhere<Task>) {
|
||||
return this.taskRepository.findOne({
|
||||
where,
|
||||
relations: ['image', 'assignedTo', 'assignedTo.customer', 'assignedTo.customer.user', 'submission'],
|
||||
relations: [
|
||||
'image',
|
||||
'assignedTo',
|
||||
'assignedTo.customer',
|
||||
'assignedTo.customer.user',
|
||||
'assignedTo.customer.user.profilePicture',
|
||||
'submission',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@ -44,6 +51,7 @@ export class TaskRepository {
|
||||
.leftJoinAndSelect('task.assignedTo', 'assignedTo')
|
||||
.leftJoinAndSelect('assignedTo.customer', 'customer')
|
||||
.leftJoinAndSelect('customer.user', 'user')
|
||||
.leftJoinAndSelect('user.profilePicture', 'profilePicture')
|
||||
.leftJoinAndSelect('task.submission', 'submission');
|
||||
|
||||
if (roles.includes(Roles.GUARDIAN)) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { IJwtPayload } from '~/auth/interfaces';
|
||||
import { OciService } from '~/document/services';
|
||||
import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request';
|
||||
import { Task } from '../entities';
|
||||
import { SubmissionStatus, TaskStatus } from '../enums';
|
||||
@ -8,7 +9,7 @@ import { TaskRepository } from '../repositories';
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(private readonly taskRepository: TaskRepository) {}
|
||||
constructor(private readonly taskRepository: TaskRepository, private readonly ociService: OciService) {}
|
||||
async createTask(userId: string, body: CreateTaskRequestDto) {
|
||||
const task = await this.taskRepository.createTask(userId, body);
|
||||
return this.findTask({ id: task.id });
|
||||
@ -24,8 +25,12 @@ export class TaskService {
|
||||
return task;
|
||||
}
|
||||
|
||||
findTasks(user: IJwtPayload, query: TasksFilterOptions) {
|
||||
return this.taskRepository.findTasks(user, query);
|
||||
async findTasks(user: IJwtPayload, query: TasksFilterOptions): Promise<[Task[], number]> {
|
||||
const [tasks, count] = await this.taskRepository.findTasks(user, query);
|
||||
|
||||
await this.prepareTasksPictures(tasks);
|
||||
|
||||
return [tasks, count];
|
||||
}
|
||||
async submitTask(userId: string, taskId: string, body: TaskSubmissionRequestDto) {
|
||||
const task = await this.findTask({ id: taskId, assignedToId: userId });
|
||||
@ -68,4 +73,26 @@ export class TaskService {
|
||||
|
||||
await this.taskRepository.rejectSubmission(task.submission);
|
||||
}
|
||||
|
||||
async prepareTasksPictures(tasks: Task[]) {
|
||||
await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
const [imageUrl, submissionUrl, profilePictureUrl] = await Promise.all([
|
||||
this.ociService.generatePreSignedUrl(task.image),
|
||||
this.ociService.generatePreSignedUrl(task.submission?.proofOfCompletion),
|
||||
this.ociService.generatePreSignedUrl(task.assignedTo.customer.user.profilePicture),
|
||||
]);
|
||||
|
||||
task.image.url = imageUrl;
|
||||
|
||||
if (task.submission) {
|
||||
task.submission.proofOfCompletion.url = submissionUrl;
|
||||
}
|
||||
|
||||
if (task.assignedTo.customer.user.profilePicture) {
|
||||
task.assignedTo.customer.user.profilePicture.url = profilePictureUrl;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user