diff --git a/packages/server/src/download-request/download-request.module.ts b/packages/server/src/download-request/download-request.module.ts index acfe5d3..700e4b0 100644 --- a/packages/server/src/download-request/download-request.module.ts +++ b/packages/server/src/download-request/download-request.module.ts @@ -16,6 +16,8 @@ import { StudyModule } from 'src/study/study.module'; import { StudyDownloadRequestResolver } from './resolvers/study-download-request.resolver'; import { StudyDownloadService } from './services/study-download-request.service'; import { TagModule } from '../tag/tag.module'; +import { StudyDownloadRequestPipe } from './pipes/study-download-request.pipe'; +import { DatasetDownloadRequestPipe } from './pipes/dataset-download-request.pipe'; @Module({ imports: [ @@ -38,7 +40,9 @@ import { TagModule } from '../tag/tag.module'; CreateDatasetDownloadPipe, CreateStudyDownloadPipe, StudyDownloadRequestResolver, - StudyDownloadService + StudyDownloadService, + StudyDownloadRequestPipe, + DatasetDownloadRequestPipe ] }) export class DownloadRequestModule {} diff --git a/packages/server/src/download-request/models/dataset-download-request.model.ts b/packages/server/src/download-request/models/dataset-download-request.model.ts index f3c074d..edef74c 100644 --- a/packages/server/src/download-request/models/dataset-download-request.model.ts +++ b/packages/server/src/download-request/models/dataset-download-request.model.ts @@ -1,7 +1,15 @@ import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; import { DownloadStatus, DownloadRequest } from './download-request.model'; -import { ObjectType, Field } from '@nestjs/graphql'; +import { ObjectType, Field, registerEnumType } from '@nestjs/graphql'; + +export enum DatasetDownloadField { + ENTRY_ZIP = 'ENTRY_ZIP' +} + +registerEnumType(DatasetDownloadField, { + name: 'DatasetDownloadField' +}); @Schema() @ObjectType() @@ -34,6 +42,12 @@ export class DatasetDownloadRequest implements DownloadRequest { @Prop({ required: false }) webhookPayloadLocation?: string; + + @Prop({ required: true }) + entryZipComplete: boolean; + + @Prop({ required: true }) + verificationCode: string; } export type DatasetDownloadRequestDocument = Document & DatasetDownloadRequest; diff --git a/packages/server/src/download-request/models/study-download-request.model.ts b/packages/server/src/download-request/models/study-download-request.model.ts index b0aa0da..8138196 100644 --- a/packages/server/src/download-request/models/study-download-request.model.ts +++ b/packages/server/src/download-request/models/study-download-request.model.ts @@ -1,7 +1,16 @@ import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; import { DownloadRequest, DownloadStatus } from './download-request.model'; -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +export enum StudyDownloadField { + ENTRY_ZIP = 'ENTRY_ZIP', + TAGGED_ENTRIES_ZIP = 'TAGGED_ENTRIES_ZIP' +} + +registerEnumType(StudyDownloadField, { + name: 'StudyDownloadField' +}); @Schema() @ObjectType() @@ -54,6 +63,15 @@ export class StudyDownloadRequest implements DownloadRequest { /** Webhook payload to be used when the zipping of tagged entries is complete */ @Prop({ required: false }) taggedEntryWebhookPayloadLocation?: string; + + @Prop({ required: true }) + entryZipComplete: boolean; + + @Prop({ required: true }) + taggedEntryZipComplete: boolean; + + @Prop({ required: true }) + verificationCode: string; } export type StudyDownloadRequestDocument = Document & StudyDownloadRequest; diff --git a/packages/server/src/download-request/pipes/dataset-download-request.pipe.ts b/packages/server/src/download-request/pipes/dataset-download-request.pipe.ts new file mode 100644 index 0000000..8c33ee2 --- /dev/null +++ b/packages/server/src/download-request/pipes/dataset-download-request.pipe.ts @@ -0,0 +1,16 @@ +import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common'; +import { DatasetDownloadRequest } from '../models/dataset-download-request.model'; +import { DatasetDownloadService } from '../services/dataset-download-request.service'; + +@Injectable() +export class DatasetDownloadRequestPipe implements PipeTransform> { + constructor(private readonly downloadRequestService: DatasetDownloadService) {} + + async transform(value: string): Promise { + const downloadRequest = await this.downloadRequestService.find(value); + if (!downloadRequest) { + throw new BadRequestException(`Dataset download request with id ${value} not found`); + } + return downloadRequest; + } +} diff --git a/packages/server/src/download-request/pipes/study-download-request.pipe.ts b/packages/server/src/download-request/pipes/study-download-request.pipe.ts new file mode 100644 index 0000000..0b08dc0 --- /dev/null +++ b/packages/server/src/download-request/pipes/study-download-request.pipe.ts @@ -0,0 +1,16 @@ +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import { StudyDownloadRequest } from '../models/study-download-request.model'; +import { StudyDownloadService } from '../services/study-download-request.service'; + +@Injectable() +export class StudyDownloadRequestPipe implements PipeTransform> { + constructor(private readonly downloadService: StudyDownloadService) {} + + async transform(value: string): Promise { + const downloadRequest = await this.downloadService.find(value); + if (!downloadRequest) { + throw new BadRequestException(`Study down with the id ${value} not found`); + } + return downloadRequest; + } +} diff --git a/packages/server/src/download-request/resolvers/dataset-download-request.resolver.ts b/packages/server/src/download-request/resolvers/dataset-download-request.resolver.ts index 04ad498..354905f 100644 --- a/packages/server/src/download-request/resolvers/dataset-download-request.resolver.ts +++ b/packages/server/src/download-request/resolvers/dataset-download-request.resolver.ts @@ -1,8 +1,8 @@ import { Resolver, Mutation, Args, Query, ID, ResolveField, Parent } from '@nestjs/graphql'; import { CreateDatasetDownloadRequest } from '../dtos/dataset-download-request-create.dto'; -import { DatasetDownloadRequest } from '../models/dataset-download-request.model'; +import { DatasetDownloadRequest, DatasetDownloadField } from '../models/dataset-download-request.model'; import { DatasetDownloadService } from '../services/dataset-download-request.service'; -import { UseGuards } from '@nestjs/common'; +import { UnauthorizedException, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../../jwt/jwt.guard'; import { OrganizationContext } from 'src/organization/organization.context'; import { Organization } from '../../organization/organization.model'; @@ -10,6 +10,7 @@ import { OrganizationGuard } from '../../organization/organization.guard'; import { CreateDatasetDownloadPipe } from '../pipes/dataset-download-request-create.pipe'; import { Dataset } from '../../dataset/dataset.model'; import { DatasetPipe } from '../../dataset/pipes/dataset.pipe'; +import { DatasetDownloadRequestPipe } from '../pipes/dataset-download-request.pipe'; @UseGuards(JwtAuthGuard, OrganizationGuard) @Resolver(() => DatasetDownloadRequest) @@ -34,6 +35,20 @@ export class DatasetDownloadRequestResolver { return this.datasetDownloadService.getDatasetDownloadRequests(dataset); } + @Mutation(() => Boolean) + async markDatasetFieldComplete( + @Args('downloadRequest', { type: () => ID }, DatasetDownloadRequestPipe) downloadRequest: DatasetDownloadRequest, + @Args('datasetField', { type: () => DatasetDownloadField }) datasetField: DatasetDownloadField, + @Args('code') verificationCode: string + ): Promise { + if (verificationCode !== downloadRequest.verificationCode) { + throw new UnauthorizedException(`Invalid verification code`); + } + + await this.datasetDownloadService.markFieldComplete(downloadRequest, datasetField); + return true; + } + @ResolveField(() => String) async entryZip(@Parent() downloadRequest: DatasetDownloadRequest): Promise { return this.datasetDownloadService.getEntryZipURL(downloadRequest); diff --git a/packages/server/src/download-request/resolvers/study-download-request.resolver.ts b/packages/server/src/download-request/resolvers/study-download-request.resolver.ts index c2aa363..32a3dc6 100644 --- a/packages/server/src/download-request/resolvers/study-download-request.resolver.ts +++ b/packages/server/src/download-request/resolvers/study-download-request.resolver.ts @@ -1,8 +1,8 @@ import { Resolver, Mutation, Args, ResolveField, Parent, ID, Query } from '@nestjs/graphql'; import { JwtAuthGuard } from '../../jwt/jwt.guard'; import { OrganizationGuard } from '../../organization/organization.guard'; -import { UseGuards } from '@nestjs/common'; -import { StudyDownloadRequest } from '../models/study-download-request.model'; +import { UnauthorizedException, UseGuards } from '@nestjs/common'; +import { StudyDownloadField, StudyDownloadRequest } from '../models/study-download-request.model'; import { StudyDownloadService } from '../services/study-download-request.service'; import { CreateStudyDownloadPipe } from '../pipes/study-download-request-create.pipe'; import { CreateStudyDownloadRequest } from '../dtos/study-download-request-create.dto'; @@ -10,6 +10,7 @@ import { OrganizationContext } from '../../organization/organization.context'; import { Organization } from '../../organization/organization.model'; import { StudyPipe } from '../../study/pipes/study.pipe'; import { Study } from '../../study/study.model'; +import { StudyDownloadRequestPipe } from '../pipes/study-download-request.pipe'; @UseGuards(JwtAuthGuard, OrganizationGuard) @Resolver(() => StudyDownloadRequest) @@ -29,6 +30,20 @@ export class StudyDownloadRequestResolver { return this.studyDownloadService.getStudyDownloads(study); } + @Mutation(() => Boolean) + async markStudyFieldComplete( + @Args('downloadRequest', { type: () => ID }, StudyDownloadRequestPipe) downloadRequest: StudyDownloadRequest, + @Args('studyField', { type: () => StudyDownloadField }) studyField: StudyDownloadField, + @Args('code') verificationCode: string + ): Promise { + if (downloadRequest.verificationCode !== verificationCode) { + throw new UnauthorizedException('Invalid verification code'); + } + + await this.studyDownloadService.markStudyFieldComplete(downloadRequest, studyField); + return true; + } + @ResolveField(() => String) async entryZip(@Parent() downloadRequest: StudyDownloadRequest): Promise { return this.studyDownloadService.getEntryZipUrl(downloadRequest); diff --git a/packages/server/src/download-request/services/dataset-download-request.service.ts b/packages/server/src/download-request/services/dataset-download-request.service.ts index 36adc3e..4240b94 100644 --- a/packages/server/src/download-request/services/dataset-download-request.service.ts +++ b/packages/server/src/download-request/services/dataset-download-request.service.ts @@ -8,9 +8,10 @@ import { BucketFactory } from '../../bucket/bucket-factory.service'; import { EntryService } from '../../entry/services/entry.service'; import { Organization } from '../../organization/organization.model'; import { CreateDatasetDownloadRequest } from '../dtos/dataset-download-request-create.dto'; -import { DatasetDownloadRequest } from '../models/dataset-download-request.model'; +import { DatasetDownloadField, DatasetDownloadRequest } from '../models/dataset-download-request.model'; import { DownloadRequest, DownloadStatus } from '../models/download-request.model'; import { DownloadRequestService } from './download-request.service'; +import { randomUUID } from 'crypto'; @Injectable() export class DatasetDownloadService { @@ -33,7 +34,9 @@ export class DatasetDownloadService { ...downloadRequest, date: new Date(), status: DownloadStatus.IN_PROGRESS, - organization: organization._id + organization: organization._id, + entryZipComplete: false, + verificationCode: randomUUID() }); const bucketLocation = `${this.downloadService.getPrefix()}/${request._id}`; @@ -88,4 +91,21 @@ export class DatasetDownloadService { new Date(Date.now() + this.expiration) ); } + + async find(id: string): Promise { + return this.downloadRequestModel.findById({ _id: id }); + } + + async markFieldComplete(downloadRequest: DatasetDownloadRequest, field: DatasetDownloadField): Promise { + switch (field) { + case DatasetDownloadField.ENTRY_ZIP: + await this.downloadRequestModel.updateOne({ _id: downloadRequest._id }, { $set: { entryZipComplete: true } }); + break; + default: + throw new Error(`Unknown dataset download field ${field}`); + } + + // With only one field supported, can mark the download as complete + await this.downloadRequestModel.updateOne({ _id: downloadRequest._id }, { $set: { status: DownloadStatus.READY } }); + } } diff --git a/packages/server/src/download-request/services/study-download-request.service.ts b/packages/server/src/download-request/services/study-download-request.service.ts index 0e5c9fb..ed86318 100644 --- a/packages/server/src/download-request/services/study-download-request.service.ts +++ b/packages/server/src/download-request/services/study-download-request.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { StudyDownloadRequest } from '../models/study-download-request.model'; +import { StudyDownloadField, StudyDownloadRequest } from '../models/study-download-request.model'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { CreateStudyDownloadRequest } from '../dtos/study-download-request-create.dto'; @@ -15,6 +15,7 @@ import { VideoFieldService } from '../../tag/services/video-field.service'; import { BucketObjectAction } from 'src/bucket/bucket'; import { Entry } from 'src/entry/models/entry.model'; import { Study } from 'src/study/study.model'; +import { randomUUID } from 'crypto'; @Injectable() export class StudyDownloadService { @@ -39,7 +40,10 @@ export class StudyDownloadService { ...downloadRequest, date: new Date(), status: DownloadStatus.IN_PROGRESS, - organization: organization._id + organization: organization._id, + entryZipComplete: false, + taggedEntryZipComplete: false, + verificationCode: randomUUID() }); const bucketLocation = `${this.downloadService.getPrefix()}/${request._id}`; @@ -102,6 +106,39 @@ export class StudyDownloadService { return this.downloadRequestModel.find({ study: study._id }); } + async find(id: string): Promise { + return this.downloadRequestModel.findById(id); + } + + /** + * Handles flagging when a field is complete and then updating the status when all fields are complete + */ + async markStudyFieldComplete(downloadRequest: StudyDownloadRequest, studyField: StudyDownloadField): Promise { + // Mark the field as complete + switch (studyField) { + case StudyDownloadField.ENTRY_ZIP: + console.log('here'); + await this.downloadRequestModel.updateOne({ _id: downloadRequest._id }, { $set: { entryZipComplete: true } }); + break; + case StudyDownloadField.TAGGED_ENTRIES_ZIP: + await this.downloadRequestModel.updateOne( + { _id: downloadRequest._id }, + { $set: { taggedEntryZipComplete: true } } + ); + break; + default: + throw new Error(`Unknown field ${studyField}`); + } + + const request = (await this.downloadRequestModel.findOne({ _id: downloadRequest._id }))!; + + // Check if all components are complete + if (request.taggedEntryZipComplete && request.entryZipComplete) { + // Mark as complete + await this.downloadRequestModel.updateOne({ _id: request._id }, { $set: { status: DownloadStatus.READY } }); + } + } + /** * Handles generating the CSV for the tag data. This approach is a sub-optimal one. *