From 020c547077eb7b485c933c60734cd3d073be6cd9 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 30 Apr 2024 16:34:20 -0400 Subject: [PATCH 01/17] Begin work on study download logic --- .../models/study-download-request.model.ts | 2 +- .../services/study-download-request.service.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/download-request/services/study-download-request.service.ts 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 c7af1bf..f6cd44b 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 @@ -20,7 +20,7 @@ export class StudyDownloadRequest implements DownloadRequest { tagCSVLocation?: string; @Prop({ required: true }) - entryZIPLocation: string; + entryZIPLocation?: string; @Prop({ required: false }) bucketLocation?: string; 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 new file mode 100644 index 0000000..6dacf61 --- /dev/null +++ b/packages/server/src/download-request/services/study-download-request.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { StudyDownloadRequest } from '../models/study-download-request.model'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + + +@Injectable() +export class StudyDownloadService { + constructor( + @InjectModel(StudyDownloadRequest.name) + private readonly downloadRequestMode: Model + ) {} + + + async createDownloadRequest() { + + } +} From d732419fab84e3234dbd88df7492d60c4c52d751 Mon Sep 17 00:00:00 2001 From: cbolles Date: Wed, 1 May 2024 11:17:59 -0400 Subject: [PATCH 02/17] Begin work on creating study download requests --- .../download-request/download-request.module.ts | 7 +++++-- .../dtos/study-download-request-create.dto.ts | 8 ++++++++ .../models/study-download-request.model.ts | 7 +++++++ .../pipes/study-download-request-create.pipe.ts | 16 ++++++++++++++++ .../services/study-download-request.service.ts | 5 +++-- packages/server/src/study/study.service.ts | 5 +++++ 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/download-request/dtos/study-download-request-create.dto.ts create mode 100644 packages/server/src/download-request/pipes/study-download-request-create.pipe.ts diff --git a/packages/server/src/download-request/download-request.module.ts b/packages/server/src/download-request/download-request.module.ts index f81ea97..6abdaef 100644 --- a/packages/server/src/download-request/download-request.module.ts +++ b/packages/server/src/download-request/download-request.module.ts @@ -11,6 +11,8 @@ import { CreateDatasetDownloadPipe } from './pipes/dataset-download-request-crea import { DatasetDownloadRequestResolver } from './resolvers/dataset-download-request.resolver'; import { DatasetDownloadService } from './services/dataset-download-request.service'; import { DownloadRequestService } from './services/download-request.service'; +import { CreateStudyDownloadPipe } from './pipes/study-download-request-create.pipe'; +import { StudyModule } from 'src/study/study.module'; @Module({ imports: [ @@ -22,8 +24,9 @@ import { DownloadRequestService } from './services/download-request.service'; DatasetModule, EntryModule, BucketModule, - GcpModule + GcpModule, + StudyModule ], - providers: [DatasetDownloadRequestResolver, DatasetDownloadService, DownloadRequestService, CreateDatasetDownloadPipe] + providers: [DatasetDownloadRequestResolver, DatasetDownloadService, DownloadRequestService, CreateDatasetDownloadPipe, CreateStudyDownloadPipe] }) export class DownloadRequestModule {} diff --git a/packages/server/src/download-request/dtos/study-download-request-create.dto.ts b/packages/server/src/download-request/dtos/study-download-request-create.dto.ts new file mode 100644 index 0000000..569eb6d --- /dev/null +++ b/packages/server/src/download-request/dtos/study-download-request-create.dto.ts @@ -0,0 +1,8 @@ +import { Field, ID, InputType, OmitType } from '@nestjs/graphql'; +import { StudyDownloadRequest } from '../models/study-download-request.model'; + +@InputType() +export class CreateStudyDownloadRequest extends OmitType(StudyDownloadRequest, ['_id', 'date', 'status'] as const, InputType) { + @Field(() => ID) + study: string; +} 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 f6cd44b..b151158 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,16 +1,23 @@ import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; import { DownloadRequest, DownloadStatus } from './download-request.model'; +import { Field, ObjectType } from '@nestjs/graphql'; @Schema() +@ObjectType() export class StudyDownloadRequest implements DownloadRequest { + @Field() + _id: string; + @Prop({ required: true }) organization: string; @Prop({ required: true }) + @Field() date: Date; @Prop({ required: true, enum: DownloadStatus }) + @Field() status: DownloadStatus; @Prop({ required: true }) diff --git a/packages/server/src/download-request/pipes/study-download-request-create.pipe.ts b/packages/server/src/download-request/pipes/study-download-request-create.pipe.ts new file mode 100644 index 0000000..ad0e788 --- /dev/null +++ b/packages/server/src/download-request/pipes/study-download-request-create.pipe.ts @@ -0,0 +1,16 @@ +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import { StudyService } from '../../study/study.service'; +import { CreateStudyDownloadRequest } from '../dtos/study-download-request-create.dto'; + +@Injectable() +export class CreateStudyDownloadPipe implements PipeTransform> { + constructor(private readonly studyService: StudyService) {} + + async transform(value: CreateStudyDownloadRequest): Promise { + const exists = await this.studyService.existsById(value.study); + if (!exists) { + throw new BadRequestException(`Study with id ${value.study} does not exist`); + } + return value; + } +} 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 6dacf61..3cc25e0 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 @@ -2,17 +2,18 @@ import { Injectable } from '@nestjs/common'; import { 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'; @Injectable() export class StudyDownloadService { constructor( @InjectModel(StudyDownloadRequest.name) - private readonly downloadRequestMode: Model + private readonly downloadRequestModel: Model ) {} - async createDownloadRequest() { + async createDownloadRequest(downloadRequest: CreateStudyDownloadRequest) { } } diff --git a/packages/server/src/study/study.service.ts b/packages/server/src/study/study.service.ts index 8671b7d..b7c6ef6 100644 --- a/packages/server/src/study/study.service.ts +++ b/packages/server/src/study/study.service.ts @@ -41,6 +41,11 @@ export class StudyService { return !!study; } + async existsById(id: string): Promise { + const study = await this.studyModel.findOne({ _id: id }); + return !!study; + } + async findById(id: string): Promise { return this.studyModel.findById(id); } From 8af71d4f202effe623033db8ae8907d059a909bf Mon Sep 17 00:00:00 2001 From: cbolles Date: Thu, 2 May 2024 10:57:57 -0400 Subject: [PATCH 03/17] Add field for signlab based recordings --- .../study-download-request.service.ts | 46 ++++++++++++++++++- .../server/src/entry/models/entry.model.ts | 26 ++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) 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 3cc25e0..aa98936 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 @@ -3,17 +3,59 @@ import { 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'; +import { DownloadStatus } from '../models/download-request.model'; +import { Organization } from '../../organization/organization.model'; +import { DownloadRequestService } from './download-request.service'; +import { Entry } from 'src/entry/models/entry.model'; @Injectable() export class StudyDownloadService { constructor( @InjectModel(StudyDownloadRequest.name) - private readonly downloadRequestModel: Model + private readonly downloadRequestModel: Model, + private readonly downloadService: DownloadRequestService ) {} - async createDownloadRequest(downloadRequest: CreateStudyDownloadRequest) { + async createDownloadRequest(downloadRequest: CreateStudyDownloadRequest, organization: Organization): Promise { + let request = await this.downloadRequestModel.create({ + ...downloadRequest, + data: new Date(), + status: DownloadStatus.IN_PROGRESS, + organization: organization._id + }); + const bucketLocation = `${this.downloadService.getPrefix()}/${request._id}`; + + // Create the locations for all the artifacts + const zipLocation = `${bucketLocation}/entries.zip`; + const entryJSONLocation = `${bucketLocation}/entries.json`; + const webhookPayloadLocation = `${bucketLocation}/webhook.json`; + + await this.downloadRequestModel.updateOne( + { _id: request._id }, + { + $set: { + bucketLocation: bucketLocation, + entryZIPLocation: zipLocation, + entryJSONLocation: entryJSONLocation, + webhookPayloadLocation: webhookPayloadLocation + } + } + ); + request = (await this.downloadRequestModel.findById(request._id))!; + + await this.startZipJob(request); + + return request; + } + + private async startZipJob(downloadRequest: StudyDownloadRequest): Promise { + + } + + private async getEntries(downloadRequest: StudyDownloadRequest): Promise { + return []; } } diff --git a/packages/server/src/entry/models/entry.model.ts b/packages/server/src/entry/models/entry.model.ts index 4643e6d..6f7c83e 100644 --- a/packages/server/src/entry/models/entry.model.ts +++ b/packages/server/src/entry/models/entry.model.ts @@ -3,6 +3,28 @@ import { ObjectType, Field, ID } from '@nestjs/graphql'; import mongoose, { Document } from 'mongoose'; import JSON from 'graphql-type-json'; +@Schema() +@ObjectType() +export class SignLabRecorded { + /** The tag the recording is associated with */ + @Prop({ required: true }) + tag: string; + + /** The name of the field within the tag */ + @Prop({ requied: true }) + @Field() + fieldName: string; + + /** The study the entry was recorded as part of */ + @Prop() + study: string; + + @Prop({ required: true }) + videoNumber: number; +} + +export const SignLabRecordedSchema = SchemaFactory.createForClass(SignLabRecorded); + @Schema() @ObjectType() export class Entry { @@ -28,7 +50,9 @@ export class Entry { @Prop({ required: true }) recordedInSignLab: boolean; - // TODO: Add info on in-SignLab recording + @Prop({ type: SignLabRecorded }) + @Field(() => SignLabRecorded, { nullable: true }) + signlabRecording?: SignLabRecorded; // TODO: Add GraphQL reference back to dataset object @Prop() From d04b5c5d1cdab458ef457902465cc2edd3baf7c3 Mon Sep 17 00:00:00 2001 From: cbolles Date: Thu, 2 May 2024 11:08:37 -0400 Subject: [PATCH 04/17] Add in field for entires recorded in signlab --- packages/server/src/entry/services/entry.service.ts | 9 +++++---- .../src/tag/services/video-field-inter.service.ts | 10 ++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/server/src/entry/services/entry.service.ts b/packages/server/src/entry/services/entry.service.ts index fe9a441..0119ef6 100644 --- a/packages/server/src/entry/services/entry.service.ts +++ b/packages/server/src/entry/services/entry.service.ts @@ -1,6 +1,6 @@ import { Injectable, Inject } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Entry } from '../models/entry.model'; +import { Entry, SignLabRecorded } from '../models/entry.model'; import { Model } from 'mongoose'; import { EntryCreate } from '../dtos/create.dto'; import { Dataset } from '../../dataset/dataset.model'; @@ -23,16 +23,17 @@ export class EntryService { return this.entryModel.findOne({ _id: entryID }); } - async create(entryCreate: EntryCreate, dataset: Dataset, user: TokenPayload, isTraining: boolean): Promise { + async create(entryCreate: EntryCreate, dataset: Dataset, user: TokenPayload, isTraining: boolean, signLabRecorded?: SignLabRecorded): Promise { // Make the entry, note that training entries are not associated with a dataset return this.entryModel.create({ ...entryCreate, dataset: dataset._id, organization: dataset.organization, - recordedInSignLab: false, + recordedInSignLab: !!signLabRecorded, dateCreated: new Date(), creator: user.user_id, - isTraining + isTraining, + signlabRecording: signLabRecorded }); } diff --git a/packages/server/src/tag/services/video-field-inter.service.ts b/packages/server/src/tag/services/video-field-inter.service.ts index 27c5ef3..e41e4c9 100644 --- a/packages/server/src/tag/services/video-field-inter.service.ts +++ b/packages/server/src/tag/services/video-field-inter.service.ts @@ -97,11 +97,17 @@ export class VideoFieldIntermediateService { { entryID: 'TODO: Generate entry ID', contentType: 'video/webm', - meta: {} + meta: {}, }, dataset, user, - tag.training + tag.training, + { + study: tag.study, + tag: tag._id, + fieldName: videoField.field, + videoNumber: videoField.index + } ); // Where to move the entry video From c0dc2d11d1b5943205a49add246e8c9fc22f54f5 Mon Sep 17 00:00:00 2001 From: cbolles Date: Thu, 2 May 2024 11:30:40 -0400 Subject: [PATCH 05/17] Working ability to download entries from a study --- .../download-request.module.ts | 14 ++++- .../models/study-download-request.model.ts | 2 +- .../study-download-request.resolver.ts | 24 +++++++ .../study-download-request.service.ts | 63 ++++++++++++++++--- .../src/entry/services/entry.service.ts | 15 +++++ 5 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 packages/server/src/download-request/resolvers/study-download-request.resolver.ts diff --git a/packages/server/src/download-request/download-request.module.ts b/packages/server/src/download-request/download-request.module.ts index 6abdaef..942498f 100644 --- a/packages/server/src/download-request/download-request.module.ts +++ b/packages/server/src/download-request/download-request.module.ts @@ -13,6 +13,8 @@ import { DatasetDownloadService } from './services/dataset-download-request.serv import { DownloadRequestService } from './services/download-request.service'; import { CreateStudyDownloadPipe } from './pipes/study-download-request-create.pipe'; import { StudyModule } from 'src/study/study.module'; +import { StudyDownloadRequestResolver } from './resolvers/study-download-request.resolver'; +import { StudyDownloadService } from './services/study-download-request.service'; @Module({ imports: [ @@ -25,8 +27,16 @@ import { StudyModule } from 'src/study/study.module'; EntryModule, BucketModule, GcpModule, - StudyModule + StudyModule, ], - providers: [DatasetDownloadRequestResolver, DatasetDownloadService, DownloadRequestService, CreateDatasetDownloadPipe, CreateStudyDownloadPipe] + providers: [ + DatasetDownloadRequestResolver, + DatasetDownloadService, + DownloadRequestService, + CreateDatasetDownloadPipe, + CreateStudyDownloadPipe, + StudyDownloadRequestResolver, + StudyDownloadService + ] }) export class DownloadRequestModule {} 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 b151158..48cee6c 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 @@ -26,7 +26,7 @@ export class StudyDownloadRequest implements DownloadRequest { @Prop({ requied: false }) tagCSVLocation?: string; - @Prop({ required: true }) + @Prop({ required: false }) entryZIPLocation?: string; @Prop({ required: false }) 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 new file mode 100644 index 0000000..867ff06 --- /dev/null +++ b/packages/server/src/download-request/resolvers/study-download-request.resolver.ts @@ -0,0 +1,24 @@ +import { Resolver, Mutation, Args } 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 { 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'; +import { OrganizationContext } from '../../organization/organization.context'; +import { Organization } from '../../organization/organization.model'; + +@UseGuards(JwtAuthGuard, OrganizationGuard) +@Resolver(() => StudyDownloadRequest) +export class StudyDownloadRequestResolver { + constructor(private readonly studyDownloadService: StudyDownloadService) {} + + @Mutation(() => StudyDownloadRequest) + async createStudyDownload( + @Args('downloadRequest', CreateStudyDownloadPipe) downloadRequest: CreateStudyDownloadRequest, + @OrganizationContext() organization: Organization + ): Promise { + return this.studyDownloadService.createDownloadRequest(downloadRequest, organization); + } +} 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 aa98936..9f0ed89 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,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; import { StudyDownloadRequest } from '../models/study-download-request.model'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; @@ -6,22 +6,32 @@ import { CreateStudyDownloadRequest } from '../dtos/study-download-request-creat import { DownloadStatus } from '../models/download-request.model'; import { Organization } from '../../organization/organization.model'; import { DownloadRequestService } from './download-request.service'; -import { Entry } from 'src/entry/models/entry.model'; - +import { EntryService } from '../../entry/services/entry.service'; +import { BucketFactory } from '../../bucket/bucket-factory.service'; +import { JOB_PROVIDER } from 'src/gcp/providers/job.provider'; +import { JobsClient } from '@google-cloud/run'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class StudyDownloadService { + private readonly zipJobName: string = this.configService.getOrThrow('downloads.jobName'); + constructor( @InjectModel(StudyDownloadRequest.name) private readonly downloadRequestModel: Model, - private readonly downloadService: DownloadRequestService + private readonly downloadService: DownloadRequestService, + private readonly entryService: EntryService, + private readonly bucketFactory: BucketFactory, + @Inject(JOB_PROVIDER) + private readonly jobsClient: JobsClient, + private readonly configService: ConfigService ) {} async createDownloadRequest(downloadRequest: CreateStudyDownloadRequest, organization: Organization): Promise { let request = await this.downloadRequestModel.create({ ...downloadRequest, - data: new Date(), + date: new Date(), status: DownloadStatus.IN_PROGRESS, organization: organization._id }); @@ -52,10 +62,47 @@ export class StudyDownloadService { } private async startZipJob(downloadRequest: StudyDownloadRequest): Promise { + // First, get the entries that need to be zipped + const entries = await this.entryService.getEntriesForStudy(downloadRequest.study); + const entryLocations = entries.map((entry) => `/buckets/${downloadRequest.organization}/${entry.bucketLocation}`); - } + // Make the content of the entry request file + const entryContent: string = JSON.stringify({ entries: entryLocations }); + + // Get the bucket for uploading supporting files + const bucket = await this.bucketFactory.getBucket(downloadRequest.organization); + if (!bucket) { + throw Error(`Bucket not found for organization ${downloadRequest.organization}`); + } + + // Write in the entries file + await bucket.writeText(downloadRequest.entryJSONLocation!, entryContent); - private async getEntries(downloadRequest: StudyDownloadRequest): Promise { - return []; + // Upload the webhook payload + // TODO: Update webhook + await bucket.writeText( + downloadRequest.webhookPayloadLocation!, + JSON.stringify({ + code: '1234', + downloadRequest: '12' + }) + ); + + // Trigger the cloud run job + await this.jobsClient.runJob({ + name: this.zipJobName, + overrides: { + containerOverrides: [ + { + args: [ + `--target_entries=/buckets/${downloadRequest.organization}/${downloadRequest.entryJSONLocation!}`, + `--output_zip=/buckets/${downloadRequest.organization}/${downloadRequest.entryZIPLocation!}`, + `--notification_webhook=http://localhost:3000`, + `--webhook_payload=/buckets/${downloadRequest.organization}/${downloadRequest.webhookPayloadLocation!}` + ] + } + ] + } + }); } } diff --git a/packages/server/src/entry/services/entry.service.ts b/packages/server/src/entry/services/entry.service.ts index 0119ef6..1adcc33 100644 --- a/packages/server/src/entry/services/entry.service.ts +++ b/packages/server/src/entry/services/entry.service.ts @@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config'; import { TokenPayload } from '../../jwt/token.dto'; import { BucketFactory } from 'src/bucket/bucket-factory.service'; import { BucketObjectAction } from 'src/bucket/bucket'; +import { Study } from 'src/study/study.model'; @Injectable() export class EntryService { @@ -70,6 +71,20 @@ export class EntryService { return bucket.getSignedUrl(entry.bucketLocation, BucketObjectAction.READ, new Date(Date.now() + this.expiration)); } + /** Get all entries recorded as part of the given study */ + async getEntriesForStudy(study: Study | string): Promise { + let studyID = ''; + if (typeof study === 'string') { + studyID = study; + } else { + studyID = study._id; + } + + return await this.entryModel.find({ + 'signlabRecording.study': studyID + }); + } + /** * Get how long the signed URL is valid for in milliseconds. * From bbff561eea86e07eaef7bc57be72faaa9c0993d6 Mon Sep 17 00:00:00 2001 From: cbolles Date: Thu, 2 May 2024 16:34:02 -0400 Subject: [PATCH 06/17] Add in less optimal solution for converting to CSV --- .../download-request.module.ts | 2 + .../study-download-request.service.ts | 47 ++++++++++++++++++- .../server/src/tag/services/tag.service.ts | 20 +++++++- packages/server/src/tag/tag.module.ts | 3 +- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/server/src/download-request/download-request.module.ts b/packages/server/src/download-request/download-request.module.ts index 942498f..acfe5d3 100644 --- a/packages/server/src/download-request/download-request.module.ts +++ b/packages/server/src/download-request/download-request.module.ts @@ -15,6 +15,7 @@ import { CreateStudyDownloadPipe } from './pipes/study-download-request-create.p 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'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { StudyDownloadService } from './services/study-download-request.service' BucketModule, GcpModule, StudyModule, + TagModule ], providers: [ DatasetDownloadRequestResolver, 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 9f0ed89..3c920f5 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 @@ -11,6 +11,9 @@ import { BucketFactory } from '../../bucket/bucket-factory.service'; import { JOB_PROVIDER } from 'src/gcp/providers/job.provider'; import { JobsClient } from '@google-cloud/run'; import { ConfigService } from '@nestjs/config'; +import { TagService } from '../../tag/services/tag.service'; +import { TagFieldType } from '../../tag/models/tag-field.model'; +import { VideoFieldService } from '../../tag/services/video-field.service'; @Injectable() export class StudyDownloadService { @@ -24,7 +27,9 @@ export class StudyDownloadService { private readonly bucketFactory: BucketFactory, @Inject(JOB_PROVIDER) private readonly jobsClient: JobsClient, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly tagService: TagService, + private readonly videoFieldService: VideoFieldService ) {} @@ -56,11 +61,49 @@ export class StudyDownloadService { ); request = (await this.downloadRequestModel.findById(request._id))!; - await this.startZipJob(request); + // await this.startZipJob(request); + await this.generateCSV(request); return request; } + /** + * Handles generating the CSV for the tag data + */ + private async generateCSV(downloadRequest: StudyDownloadRequest): Promise { + const tags = await this.tagService.getCompleteTags(downloadRequest.study); + + const converted: any[] = []; + + for (const tag of tags) { + const tagFields: any = {}; + + for (const field of tag.data!) { + + // For video fields, each entry is represented by the filename + if (field.type == TagFieldType.VIDEO_RECORD) { + const videoField = (await this.videoFieldService.find(field.data))!; + for (let index = 0; index < videoField.entries.length; index++) { + const entryID = videoField.entries[index]; + const entry = (await this.entryService.find(entryID))!; + tagFields[`${field.name}-${index}`] = entry.bucketLocation.split('/').pop(); + } + } else { + tagFields[`${field.name}`] = field.data; + } + + } + converted.push(tagFields); + } + + console.log(converted); + + // Convert the tag fields into a list of objects + // const tagData = JSON.stringify(tags); + // console.log(tagData); + + } + private async startZipJob(downloadRequest: StudyDownloadRequest): Promise { // First, get the entries that need to be zipped const entries = await this.entryService.getEntriesForStudy(downloadRequest.study); diff --git a/packages/server/src/tag/services/tag.service.ts b/packages/server/src/tag/services/tag.service.ts index 1fcbc9a..1f5dd0a 100644 --- a/packages/server/src/tag/services/tag.service.ts +++ b/packages/server/src/tag/services/tag.service.ts @@ -229,8 +229,24 @@ export class TagService { return true; } - async getTags(study: Study): Promise { - return this.tagModel.find({ study: study._id, training: false }); + async getTags(study: Study | string): Promise { + let studyID = ''; + if (typeof study === 'string') { + studyID = study; + } else { + studyID = study._id; + } + return this.tagModel.find({ study: studyID, training: false }); + } + + async getCompleteTags(study: Study | string): Promise { + let studyID = ''; + if (typeof study === 'string') { + studyID = study; + } else { + studyID = study._id; + } + return this.tagModel.find({ study: studyID, training: false, complete: true }); } private async getIncomplete(study: Study, user: string): Promise { diff --git a/packages/server/src/tag/tag.module.ts b/packages/server/src/tag/tag.module.ts index 8c4eae5..d971b32 100644 --- a/packages/server/src/tag/tag.module.ts +++ b/packages/server/src/tag/tag.module.ts @@ -67,6 +67,7 @@ import { VideoFieldResolver } from './resolvers/video-field.resolver'; AslLexFieldTransformer, VideoFieldService, VideoFieldResolver - ] + ], + exports: [TagService, VideoFieldService] }) export class TagModule {} From 442e7689c5c3d7130fc2c14d6842181c53866a35 Mon Sep 17 00:00:00 2001 From: cbolles Date: Thu, 2 May 2024 16:38:32 -0400 Subject: [PATCH 07/17] Add disclaimer to code block --- .../services/study-download-request.service.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 3c920f5..8a18f41 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 @@ -68,7 +68,22 @@ export class StudyDownloadService { } /** - * Handles generating the CSV for the tag data + * Handles generating the CSV for the tag data. This approach is a sub-optimal one. + * + * The overall need is to convert the tag information into a flat CSV format where + * any external information (like videos that are downloaded as a zip) can be associated + * with the data. + * + * For example, video fields need to be linked to the videos that are downloaded, + * therefore the video fields show up as multiple columns, one for each video recorded. + * + * This approach is sub-optimal for a number of reasons + * 1. The code should be isolated into different handlers that each know how to make + * the CSV representation for that field. + * 2. Expansion of video fields can be time consuming. This may need to be a process + * that runs in the background. + * 3. ASL-LEX fields are not expanded. Currently only the ID of the field will be + * stored */ private async generateCSV(downloadRequest: StudyDownloadRequest): Promise { const tags = await this.tagService.getCompleteTags(downloadRequest.study); From 8dc20e64a57b2bead4ab6df24bb3edf902d159e6 Mon Sep 17 00:00:00 2001 From: cbolles Date: Thu, 2 May 2024 16:52:49 -0400 Subject: [PATCH 08/17] Handling of basic CSV storage --- .../study-download-request.service.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) 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 8a18f41..8b0422b 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 @@ -47,6 +47,7 @@ export class StudyDownloadService { const zipLocation = `${bucketLocation}/entries.zip`; const entryJSONLocation = `${bucketLocation}/entries.json`; const webhookPayloadLocation = `${bucketLocation}/webhook.json`; + const tagCSVLocation = `${bucketLocation}/tag.csv`; await this.downloadRequestModel.updateOne( { _id: request._id }, @@ -55,7 +56,8 @@ export class StudyDownloadService { bucketLocation: bucketLocation, entryZIPLocation: zipLocation, entryJSONLocation: entryJSONLocation, - webhookPayloadLocation: webhookPayloadLocation + webhookPayloadLocation: webhookPayloadLocation, + tagCSVLocation: tagCSVLocation } } ); @@ -88,8 +90,8 @@ export class StudyDownloadService { private async generateCSV(downloadRequest: StudyDownloadRequest): Promise { const tags = await this.tagService.getCompleteTags(downloadRequest.study); + // Turn the tag fields into their "CSV-friendly" format const converted: any[] = []; - for (const tag of tags) { const tagFields: any = {}; @@ -111,12 +113,23 @@ export class StudyDownloadService { converted.push(tagFields); } - console.log(converted); + // Convert the data into a CSV + const dataString = this.convertToCSV(converted); + + // Store the CSV in the expected location in the bucket + const bucket = await this.bucketFactory.getBucket(downloadRequest.organization); + if (!bucket) { + throw new Error(`No bucket found for organization ${downloadRequest.organization}`); + } + await bucket.writeText(downloadRequest.tagCSVLocation!, dataString); + } - // Convert the tag fields into a list of objects - // const tagData = JSON.stringify(tags); - // console.log(tagData); + private convertToCSV(arr: any[]): string { + const array = [Object.keys(arr[0])].concat(arr) + return array.map(it => { + return Object.values(it).toString() + }).join('\n') } private async startZipJob(downloadRequest: StudyDownloadRequest): Promise { From 76d6a2d684806091b249d37dec2055a17a286340 Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 09:55:21 -0400 Subject: [PATCH 09/17] Add field resolver for the entry zip download --- .../study-download-request.resolver.ts | 7 ++++++- .../services/study-download-request.service.ts | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) 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 867ff06..99fd8d4 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,4 +1,4 @@ -import { Resolver, Mutation, Args } from '@nestjs/graphql'; +import { Resolver, Mutation, Args, ResolveField, Parent } from '@nestjs/graphql'; import { JwtAuthGuard } from '../../jwt/jwt.guard'; import { OrganizationGuard } from '../../organization/organization.guard'; import { UseGuards } from '@nestjs/common'; @@ -21,4 +21,9 @@ export class StudyDownloadRequestResolver { ): Promise { return this.studyDownloadService.createDownloadRequest(downloadRequest, organization); } + + @ResolveField(() => String) + async entryZip(@Parent() downloadRequest: StudyDownloadRequest): Promise { + return this.studyDownloadService.getEntryZipUrl(downloadRequest); + } } 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 8b0422b..2e5b422 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 @@ -14,10 +14,13 @@ import { ConfigService } from '@nestjs/config'; import { TagService } from '../../tag/services/tag.service'; import { TagFieldType } from '../../tag/models/tag-field.model'; import { VideoFieldService } from '../../tag/services/video-field.service'; +import { BucketObjectAction } from 'src/bucket/bucket'; @Injectable() export class StudyDownloadService { private readonly zipJobName: string = this.configService.getOrThrow('downloads.jobName'); + private readonly expiration = this.configService.getOrThrow('entry.signedURLExpiration'); + constructor( @InjectModel(StudyDownloadRequest.name) @@ -63,7 +66,7 @@ export class StudyDownloadService { ); request = (await this.downloadRequestModel.findById(request._id))!; - // await this.startZipJob(request); + await this.startZipJob(request); await this.generateCSV(request); return request; @@ -124,6 +127,18 @@ export class StudyDownloadService { await bucket.writeText(downloadRequest.tagCSVLocation!, dataString); } + async getEntryZipUrl(downloadRequest: StudyDownloadRequest): Promise { + const bucket = await this.bucketFactory.getBucket(downloadRequest.organization); + if (!bucket) { + throw new Error(`Bucket not found for organization ${downloadRequest.organization}`); + } + return bucket.getSignedUrl( + downloadRequest.entryZIPLocation!, + BucketObjectAction.READ, + new Date(Date.now() + this.expiration) + ) + } + private convertToCSV(arr: any[]): string { const array = [Object.keys(arr[0])].concat(arr) From 0ec6a3ba7e2587ff01698f0fa0419afeef3cdf45 Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 11:35:45 -0400 Subject: [PATCH 10/17] Add in standard approach for executing the ZIP job --- .../dataset-download-request.service.ts | 62 +++------------ .../services/download-request.service.ts | 76 ++++++++++++++++++- .../study-download-request.service.ts | 3 + 3 files changed, 88 insertions(+), 53 deletions(-) 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 bf24a1b..c37d869 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 @@ -1,11 +1,9 @@ -import { JobsClient } from '@google-cloud/run'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { BucketObjectAction } from 'src/bucket/bucket'; import { Dataset } from 'src/dataset/dataset.model'; -import { JOB_PROVIDER } from 'src/gcp/providers/job.provider'; import { BucketFactory } from '../../bucket/bucket-factory.service'; import { EntryService } from '../../entry/services/entry.service'; import { Organization } from '../../organization/organization.model'; @@ -16,7 +14,6 @@ import { DownloadRequestService } from './download-request.service'; @Injectable() export class DatasetDownloadService { - private readonly zipJobName: string = this.configService.getOrThrow('downloads.jobName'); private readonly expiration = this.configService.getOrThrow('entry.signedURLExpiration'); constructor( @@ -25,7 +22,6 @@ export class DatasetDownloadService { private readonly downloadService: DownloadRequestService, private readonly entryService: EntryService, private readonly bucketFactory: BucketFactory, - @Inject(JOB_PROVIDER) private readonly jobsClient: JobsClient, private readonly configService: ConfigService ) {} @@ -61,7 +57,16 @@ export class DatasetDownloadService { request = (await this.downloadRequestModel.findById(request._id))!; // Start the process of zipping the entries - await this.startZipJob(request); + await this.downloadService.startZipJob({ + entryJSONLocation: downloadRequest.entryJSONLocation!, + entryZIPLocation: downloadRequest.entryZIPLocation!, + webhookPayloadLocation: downloadRequest.webhookPayloadLocation!, + webhookPayload: JSON.stringify({ test: 'hello' }), + webhook: 'http://localhost:3000/', + entries: await this.entryService.findForDataset(downloadRequest.dataset), + bucket: (await this.bucketFactory.getBucket(downloadRequest.organization))!, + organization: downloadRequest.organization + }); return request; } @@ -83,49 +88,4 @@ export class DatasetDownloadService { new Date(Date.now() + this.expiration) ); } - - private async startZipJob(downloadRequest: DatasetDownloadRequest): Promise { - // First, get the entries that need to be zipped - const entries = await this.entryService.findForDataset(downloadRequest.dataset); - const entryLocations = entries.map((entry) => `/buckets/${downloadRequest.organization}/${entry.bucketLocation}`); - - // Make the content of the entry request file - const entryContent: string = JSON.stringify({ entries: entryLocations }); - - // Get the bucket for uploading supporting files - const bucket = await this.bucketFactory.getBucket(downloadRequest.organization); - if (!bucket) { - throw Error(`Bucket not found for organization ${downloadRequest.organization}`); - } - - // Write in the entries file - await bucket.writeText(downloadRequest.entryJSONLocation!, entryContent); - - // Upload the webhook payload - // TODO: Update webhook - await bucket.writeText( - downloadRequest.webhookPayloadLocation!, - JSON.stringify({ - code: '1234', - downloadRequest: '12' - }) - ); - - // Trigger the cloud run job - await this.jobsClient.runJob({ - name: this.zipJobName, - overrides: { - containerOverrides: [ - { - args: [ - `--target_entries=/buckets/${downloadRequest.organization}/${downloadRequest.entryJSONLocation!}`, - `--output_zip=/buckets/${downloadRequest.organization}/${downloadRequest.entryZIPLocation!}`, - `--notification_webhook=http://localhost:3000`, - `--webhook_payload=/buckets/${downloadRequest.organization}/${downloadRequest.webhookPayloadLocation!}` - ] - } - ] - } - }); - } } diff --git a/packages/server/src/download-request/services/download-request.service.ts b/packages/server/src/download-request/services/download-request.service.ts index e07edb6..8409c1f 100644 --- a/packages/server/src/download-request/services/download-request.service.ts +++ b/packages/server/src/download-request/services/download-request.service.ts @@ -1,11 +1,49 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Entry } from '../../entry/models/entry.model'; +import { Bucket } from '../../bucket/bucket'; +import { JOB_PROVIDER } from '../../gcp/providers/job.provider'; +import { JobsClient } from '@google-cloud/run'; + + +export interface ZipJobRequest { + /** Where to put the entry JSON file in the bucket */ + entryJSONLocation: string; + + /** The location in the bucket to put the zip */ + entryZIPLocation: string; + + /** Where the webhook payload should be placed */ + webhookPayloadLocation: string; + + /** The webhook payload */ + webhookPayload: string; + + /** The webhook endpoint */ + webhook: string; + + /** The entries that need to be zipped */ + entries: Entry[]; + + /** The bucket to upload into */ + bucket: Bucket; + + /** The organization ID */ + organization: string; +} @Injectable() export class DownloadRequestService { + /** Where in the organization bucket all downloads are stored */ private readonly downloadPrefix: string = this.configService.getOrThrow('downloads.bucketPrefix'); + /** The name of the GCP Job */ + private readonly zipJobName: string = this.configService.getOrThrow('downloads.jobName'); - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + @Inject(JOB_PROVIDER) + private readonly jobsClient: JobsClient + ) {} /** Get a bucket location for download requests given the filename */ getBucketLocation(fileName: string): string { @@ -15,4 +53,38 @@ export class DownloadRequestService { getPrefix(): string { return this.downloadPrefix; } + + async startZipJob(request: ZipJobRequest): Promise { + const mountPoint = `/buckets/${request.organization}`; + + // Get the location of each entry based on the prefix. This will be where the + // entry is located for the GCP Cloud Run Job + const entryLocations = request.entries.map((entry) => `${mountPoint}/${entry.bucketLocation}`); + + // Convert the list to a string for saving + const entryContent: string = JSON.stringify(entryLocations); + + // Now upload the generated JSON file with the entry locations into the bucket + await request.bucket.writeText(request.entryJSONLocation, entryContent); + + // Upload the webhook payload + await request.bucket.writeText(request.webhookPayloadLocation, request.webhookPayload); + + // Trigger the cloud run job + await this.jobsClient.runJob({ + name: this.zipJobName, + overrides: { + containerOverrides: [ + { + args: [ + `--target_entries=${mountPoint}/${request.entryJSONLocation}`, + `--output_zip=${mountPoint}/${request.entryZIPLocation}`, + `--notification_webhook=http://localhost:3000`, + `--webhook_payload=${mountPoint}/${request.webhookPayloadLocation}` + ] + } + ] + } + }); + } } 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 2e5b422..8b6b0f9 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 @@ -66,8 +66,11 @@ export class StudyDownloadService { ); request = (await this.downloadRequestModel.findById(request._id))!; + // Download the entries that were generated as part of this study await this.startZipJob(request); + // Download the tag data as a CSV await this.generateCSV(request); + // Download the entries that were tagged in this study return request; } From 2c8e988d8cd582fda82f71ab13f5055bb0f10882 Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 12:01:13 -0400 Subject: [PATCH 11/17] Cleanup Zip request service --- .../dataset-download-request-create.dto.ts | 2 +- .../dtos/study-download-request-create.dto.ts | 14 ++- .../models/study-download-request.model.ts | 17 ++++ .../dataset-download-request.service.ts | 12 +-- .../study-download-request.service.ts | 97 ++++++++++--------- 5 files changed, 86 insertions(+), 56 deletions(-) diff --git a/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts b/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts index e731ce6..2ea3183 100644 --- a/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts +++ b/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts @@ -4,7 +4,7 @@ import { DatasetDownloadRequest } from '../models/dataset-download-request.model @InputType() export class CreateDatasetDownloadRequest extends OmitType( DatasetDownloadRequest, - ['_id', 'date', 'status'] as const, + ['_id', 'date', 'status', 'entryZIPLocation', 'bucketLocation', 'entryJSONLocation', 'webhookPayloadLocation'] as const, InputType ) { @Field(() => ID) diff --git a/packages/server/src/download-request/dtos/study-download-request-create.dto.ts b/packages/server/src/download-request/dtos/study-download-request-create.dto.ts index 569eb6d..5ea3340 100644 --- a/packages/server/src/download-request/dtos/study-download-request-create.dto.ts +++ b/packages/server/src/download-request/dtos/study-download-request-create.dto.ts @@ -2,7 +2,19 @@ import { Field, ID, InputType, OmitType } from '@nestjs/graphql'; import { StudyDownloadRequest } from '../models/study-download-request.model'; @InputType() -export class CreateStudyDownloadRequest extends OmitType(StudyDownloadRequest, ['_id', 'date', 'status'] as const, InputType) { +export class CreateStudyDownloadRequest extends OmitType(StudyDownloadRequest, [ + '_id', + 'date', + 'status', + 'tagCSVLocation', + 'entryZIPLocation', + 'bucketLocation', + 'entryZIPLocation', + 'webhookPayloadLocation', + 'taggedEntriesJSONLocation', + 'taggedEntriesZipLocation', + 'taggedEntryWebhookPayloadLocation' +] as const, InputType) { @Field(() => ID) study: string; } 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 48cee6c..b0aa0da 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 @@ -23,20 +23,37 @@ export class StudyDownloadRequest implements DownloadRequest { @Prop({ required: true }) study: string; + /** Location in a bucket where the tag data as a CSV should be stored */ @Prop({ requied: false }) tagCSVLocation?: string; + /** Location in a bucket where any entries recorded as part of a study will be */ @Prop({ required: false }) entryZIPLocation?: string; + /** The prefix for all bucket locations */ @Prop({ required: false }) bucketLocation?: string; + /** Where the JSON list of entries recorded as part of the study will be */ @Prop({ required: false }) entryJSONLocation?: string; + /** Webhook payload to be called when the zipping of entries recorded in the study is complete */ @Prop({ required: false }) webhookPayloadLocation?: string; + + /** Location in a bucket where the entries tagged will be stored */ + @Prop({ required: false }) + taggedEntriesZipLocation?: string; + + /** Location in a bucket where the JSON list of entries tagged as part of the study will be */ + @Prop({ required: false }) + taggedEntriesJSONLocation?: string; + + /** Webhook payload to be used when the zipping of tagged entries is complete */ + @Prop({ required: false }) + taggedEntryWebhookPayloadLocation?: string; } export type StudyDownloadRequestDocument = Document & StudyDownloadRequest; 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 c37d869..36adc3e 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 @@ -58,14 +58,14 @@ export class DatasetDownloadService { // Start the process of zipping the entries await this.downloadService.startZipJob({ - entryJSONLocation: downloadRequest.entryJSONLocation!, - entryZIPLocation: downloadRequest.entryZIPLocation!, - webhookPayloadLocation: downloadRequest.webhookPayloadLocation!, + entryJSONLocation: request.entryJSONLocation!, + entryZIPLocation: request.entryZIPLocation!, + webhookPayloadLocation: request.webhookPayloadLocation!, webhookPayload: JSON.stringify({ test: 'hello' }), webhook: 'http://localhost:3000/', - entries: await this.entryService.findForDataset(downloadRequest.dataset), - bucket: (await this.bucketFactory.getBucket(downloadRequest.organization))!, - organization: downloadRequest.organization + entries: await this.entryService.findForDataset(request.dataset), + bucket: (await this.bucketFactory.getBucket(request.organization))!, + organization: request.organization }); return request; 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 8b6b0f9..60da0e3 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,4 +1,4 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { StudyDownloadRequest } from '../models/study-download-request.model'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; @@ -8,17 +8,15 @@ import { Organization } from '../../organization/organization.model'; import { DownloadRequestService } from './download-request.service'; import { EntryService } from '../../entry/services/entry.service'; import { BucketFactory } from '../../bucket/bucket-factory.service'; -import { JOB_PROVIDER } from 'src/gcp/providers/job.provider'; -import { JobsClient } from '@google-cloud/run'; import { ConfigService } from '@nestjs/config'; import { TagService } from '../../tag/services/tag.service'; import { TagFieldType } from '../../tag/models/tag-field.model'; import { VideoFieldService } from '../../tag/services/video-field.service'; import { BucketObjectAction } from 'src/bucket/bucket'; +import { Entry } from 'src/entry/models/entry.model'; @Injectable() export class StudyDownloadService { - private readonly zipJobName: string = this.configService.getOrThrow('downloads.jobName'); private readonly expiration = this.configService.getOrThrow('entry.signedURLExpiration'); @@ -28,11 +26,9 @@ export class StudyDownloadService { private readonly downloadService: DownloadRequestService, private readonly entryService: EntryService, private readonly bucketFactory: BucketFactory, - @Inject(JOB_PROVIDER) - private readonly jobsClient: JobsClient, private readonly configService: ConfigService, private readonly tagService: TagService, - private readonly videoFieldService: VideoFieldService + private readonly videoFieldService: VideoFieldService, ) {} @@ -51,6 +47,9 @@ export class StudyDownloadService { const entryJSONLocation = `${bucketLocation}/entries.json`; const webhookPayloadLocation = `${bucketLocation}/webhook.json`; const tagCSVLocation = `${bucketLocation}/tag.csv`; + const taggedEntriesZipLocation = `${bucketLocation}/tagged_entries.zip`; + const taggedEntriesJSONLocation = `${bucketLocation}/tagged_entries.json`; + const taggedEntryWebhookPayloadLocation = `${bucketLocation}/tagged_entries_webhook.json`; await this.downloadRequestModel.updateOne( { _id: request._id }, @@ -60,17 +59,39 @@ export class StudyDownloadService { entryZIPLocation: zipLocation, entryJSONLocation: entryJSONLocation, webhookPayloadLocation: webhookPayloadLocation, - tagCSVLocation: tagCSVLocation + tagCSVLocation: tagCSVLocation, + taggedEntriesZipLocation: taggedEntriesZipLocation, + taggedEntriesJSONLocation: taggedEntriesJSONLocation, + taggedEntryWebhookPayloadLocation: taggedEntryWebhookPayloadLocation } } ); request = (await this.downloadRequestModel.findById(request._id))!; // Download the entries that were generated as part of this study - await this.startZipJob(request); + await this.downloadService.startZipJob({ + entryJSONLocation: request.entryJSONLocation!, + entryZIPLocation: request.entryZIPLocation!, + webhookPayloadLocation: request.webhookPayloadLocation!, + webhookPayload: JSON.stringify({ test: 'hello' }), + webhook: 'http://localhost:3000', + entries: await this.entryService.getEntriesForStudy(request.study), + bucket: (await this.bucketFactory.getBucket(request.organization))!, + organization: request.organization + }); // Download the tag data as a CSV await this.generateCSV(request); // Download the entries that were tagged in this study + await this.downloadService.startZipJob({ + entryJSONLocation: request.taggedEntriesJSONLocation!, + entryZIPLocation: request.taggedEntriesZipLocation!, + webhookPayloadLocation: request.taggedEntryWebhookPayloadLocation!, + webhookPayload: JSON.stringify({ test: 'hello' }), + webhook: 'http://localhost:3000', + entries: await this.getLabeledEntries(request), + bucket: (await this.bucketFactory.getBucket(request.organization))!, + organization: request.organization + }); return request; } @@ -142,6 +163,9 @@ export class StudyDownloadService { ) } + /** + * TODO: Improve the CSV process, need a better method to determine the headers and handle default values + */ private convertToCSV(arr: any[]): string { const array = [Object.keys(arr[0])].concat(arr) @@ -150,48 +174,25 @@ export class StudyDownloadService { }).join('\n') } - private async startZipJob(downloadRequest: StudyDownloadRequest): Promise { - // First, get the entries that need to be zipped - const entries = await this.entryService.getEntriesForStudy(downloadRequest.study); - const entryLocations = entries.map((entry) => `/buckets/${downloadRequest.organization}/${entry.bucketLocation}`); - // Make the content of the entry request file - const entryContent: string = JSON.stringify({ entries: entryLocations }); - - // Get the bucket for uploading supporting files - const bucket = await this.bucketFactory.getBucket(downloadRequest.organization); - if (!bucket) { - throw Error(`Bucket not found for organization ${downloadRequest.organization}`); - } + /** + * Get the entries taged as part of the study + */ + private async getLabeledEntries(downloadRequest: StudyDownloadRequest): Promise { + // Get the complete tags + const tags = await this.tagService.getCompleteTags(downloadRequest.study); - // Write in the entries file - await bucket.writeText(downloadRequest.entryJSONLocation!, entryContent); - - // Upload the webhook payload - // TODO: Update webhook - await bucket.writeText( - downloadRequest.webhookPayloadLocation!, - JSON.stringify({ - code: '1234', - downloadRequest: '12' - }) - ); + // Get the entries, make sure they are unique + let entryIDs: string[] = tags.map((tag) => tag.entry); + entryIDs = Array.from(new Set(entryIDs)); - // Trigger the cloud run job - await this.jobsClient.runJob({ - name: this.zipJobName, - overrides: { - containerOverrides: [ - { - args: [ - `--target_entries=/buckets/${downloadRequest.organization}/${downloadRequest.entryJSONLocation!}`, - `--output_zip=/buckets/${downloadRequest.organization}/${downloadRequest.entryZIPLocation!}`, - `--notification_webhook=http://localhost:3000`, - `--webhook_payload=/buckets/${downloadRequest.organization}/${downloadRequest.webhookPayloadLocation!}` - ] - } - ] + // Get all the entries + return Promise.all(entryIDs.map(async (id) => { + const entry = await this.entryService.find(id); + if (!entry) { + throw new Error(`Invalid id for entry: ${id}`); } - }); + return entry; + })); } } From ba6748761b6cf04d1311f52168140629d8f5676f Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 12:16:20 -0400 Subject: [PATCH 12/17] Working ability to download entries which were tagged --- .../src/download-request/services/download-request.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/download-request/services/download-request.service.ts b/packages/server/src/download-request/services/download-request.service.ts index 8409c1f..0dc522d 100644 --- a/packages/server/src/download-request/services/download-request.service.ts +++ b/packages/server/src/download-request/services/download-request.service.ts @@ -62,7 +62,7 @@ export class DownloadRequestService { const entryLocations = request.entries.map((entry) => `${mountPoint}/${entry.bucketLocation}`); // Convert the list to a string for saving - const entryContent: string = JSON.stringify(entryLocations); + const entryContent: string = JSON.stringify({ 'entries': entryLocations }); // Now upload the generated JSON file with the entry locations into the bucket await request.bucket.writeText(request.entryJSONLocation, entryContent); From 82aaf1553e58691eebbae6c18ecb5a9dcf87d809 Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 14:04:14 -0400 Subject: [PATCH 13/17] Begin work on study download view --- .../client/public/locales/en/translation.json | 3 +- packages/client/src/App.tsx | 2 + .../src/components/SideBar.component.tsx | 3 +- packages/client/src/graphql/graphql.ts | 30 +++++++++++++ .../client/src/graphql/study/study.graphql | 7 +++ packages/client/src/graphql/study/study.ts | 45 ++++++++++++++++++- .../src/pages/studies/StudyDownloads.tsx | 13 ++++++ .../study-download-request.resolver.ts | 11 ++++- .../study-download-request.service.ts | 5 +++ 9 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 packages/client/src/pages/studies/StudyDownloads.tsx diff --git a/packages/client/public/locales/en/translation.json b/packages/client/public/locales/en/translation.json index 2584139..0905cc9 100644 --- a/packages/client/public/locales/en/translation.json +++ b/packages/client/public/locales/en/translation.json @@ -55,7 +55,8 @@ "contribute": "Contribute", "tagInStudy": "Tag in Study", "logout": "Logout", - "datasetDownloads": "Dataset Downloads" + "datasetDownloads": "Dataset Downloads", + "studyDownloads": "Study Downloads" }, "components": { "environment": { diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index fc08974..1d5a7ba 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -31,6 +31,7 @@ import { PermissionProvider } from './context/Permission.context'; import { TagTrainingView } from './pages/studies/TagTrainingView'; import { SnackbarProvider } from './context/Snackbar.context'; import { DatasetDownloads } from './pages/datasets/DatasetDownloads'; +import { StudyDownloads } from './pages/studies/StudyDownloads'; const drawerWidth = 256; const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{ @@ -135,6 +136,7 @@ const MyRoutes: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/client/src/components/SideBar.component.tsx b/packages/client/src/components/SideBar.component.tsx index 00b7ee5..e37073a 100644 --- a/packages/client/src/components/SideBar.component.tsx +++ b/packages/client/src/components/SideBar.component.tsx @@ -52,7 +52,8 @@ export const SideBar: FC = ({ open, drawerWidth }) => { visible: (p) => p!.studyAdmin }, { name: t('menu.entryControl'), action: () => navigate('/study/entries'), visible: (p) => p!.studyAdmin }, - { name: t('menu.viewTags'), action: () => navigate('/study/tags'), visible: (p) => p!.studyAdmin } + { name: t('menu.viewTags'), action: () => navigate('/study/tags'), visible: (p) => p!.studyAdmin }, + { name: t('menu.studyDownloads'), action: () => navigate('/study/downloads'), visible: (p) => p!.studyAdmin } ] }, { diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 431b442..9d1f639 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -32,6 +32,10 @@ export type CreateDatasetDownloadRequest = { dataset: Scalars['ID']['input']; }; +export type CreateStudyDownloadRequest = { + study: Scalars['ID']['input']; +}; + export type Dataset = { __typename?: 'Dataset'; _id: Scalars['ID']['output']; @@ -78,6 +82,7 @@ export type Entry = { signedUrl: Scalars['String']['output']; /** Get the number of milliseconds the signed URL is valid for. */ signedUrlExpiration: Scalars['Float']['output']; + signlabRecording?: Maybe; }; export type FreeTextField = { @@ -146,6 +151,7 @@ export type Mutation = { createDatasetDownload: DatasetDownloadRequest; createOrganization: Organization; createStudy: Study; + createStudyDownload: StudyDownloadRequest; createTags: Array; createTrainingSet: Scalars['Boolean']['output']; createUploadSession: UploadSession; @@ -229,6 +235,11 @@ export type MutationCreateStudyArgs = { }; +export type MutationCreateStudyDownloadArgs = { + downloadRequest: CreateStudyDownloadRequest; +}; + + export type MutationCreateTagsArgs = { entries: Array; study: Scalars['ID']['input']; @@ -410,6 +421,7 @@ export type Query = { getProjectPermissions: Array; getProjects: Array; getRoles: Permission; + getStudyDownloads: Array; getStudyPermissions: Array; getTags: Array; getTrainingTags: Array; @@ -486,6 +498,11 @@ export type QueryGetRolesArgs = { }; +export type QueryGetStudyDownloadsArgs = { + study: Scalars['ID']['input']; +}; + + export type QueryGetStudyPermissionsArgs = { study: Scalars['ID']['input']; }; @@ -535,6 +552,11 @@ export type QueryValidateCsvArgs = { session: Scalars['ID']['input']; }; +export type SignLabRecorded = { + __typename?: 'SignLabRecorded'; + fieldName: Scalars['String']['output']; +}; + export type SliderField = { __typename?: 'SliderField'; value: Scalars['Float']['output']; @@ -560,6 +582,14 @@ export type StudyCreate = { tagsPerEntry: Scalars['Float']['input']; }; +export type StudyDownloadRequest = { + __typename?: 'StudyDownloadRequest'; + _id: Scalars['String']['output']; + date: Scalars['DateTime']['output']; + entryZip: Scalars['String']['output']; + status: Scalars['String']['output']; +}; + export type StudyPermissionModel = { __typename?: 'StudyPermissionModel'; isContributor: Scalars['Boolean']['output']; diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql index 2d88066..7d90748 100644 --- a/packages/client/src/graphql/study/study.graphql +++ b/packages/client/src/graphql/study/study.graphql @@ -35,3 +35,10 @@ mutation createStudy($study: StudyCreate!) { query studyExists($name: String!, $project: ID!) { studyExists(name: $name, project: $project) } + +query getStudyDownloads($study: ID!) { + getStudyDownloads(study: $study) { + date, + status + } +} diff --git a/packages/client/src/graphql/study/study.ts b/packages/client/src/graphql/study/study.ts index 7d85de4..0ed71e5 100644 --- a/packages/client/src/graphql/study/study.ts +++ b/packages/client/src/graphql/study/study.ts @@ -34,6 +34,13 @@ export type StudyExistsQueryVariables = Types.Exact<{ export type StudyExistsQuery = { __typename?: 'Query', studyExists: boolean }; +export type GetStudyDownloadsQueryVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; +}>; + + +export type GetStudyDownloadsQuery = { __typename?: 'Query', getStudyDownloads: Array<{ __typename?: 'StudyDownloadRequest', date: any, status: string }> }; + export const FindStudiesDocument = gql` query findStudies($project: ID!) { @@ -185,4 +192,40 @@ export function useStudyExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOption } export type StudyExistsQueryHookResult = ReturnType; export type StudyExistsLazyQueryHookResult = ReturnType; -export type StudyExistsQueryResult = Apollo.QueryResult; \ No newline at end of file +export type StudyExistsQueryResult = Apollo.QueryResult; +export const GetStudyDownloadsDocument = gql` + query getStudyDownloads($study: ID!) { + getStudyDownloads(study: $study) { + date + status + } +} + `; + +/** + * __useGetStudyDownloadsQuery__ + * + * To run a query within a React component, call `useGetStudyDownloadsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetStudyDownloadsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetStudyDownloadsQuery({ + * variables: { + * study: // value for 'study' + * }, + * }); + */ +export function useGetStudyDownloadsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetStudyDownloadsDocument, options); + } +export function useGetStudyDownloadsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetStudyDownloadsDocument, options); + } +export type GetStudyDownloadsQueryHookResult = ReturnType; +export type GetStudyDownloadsLazyQueryHookResult = ReturnType; +export type GetStudyDownloadsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/client/src/pages/studies/StudyDownloads.tsx b/packages/client/src/pages/studies/StudyDownloads.tsx new file mode 100644 index 0000000..4a4e656 --- /dev/null +++ b/packages/client/src/pages/studies/StudyDownloads.tsx @@ -0,0 +1,13 @@ +import { DataGrid } from '@mui/x-data-grid'; + + + +export const StudyDownloads: React.FC = () => { + + return ( + + ); +}; 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 99fd8d4..f8e871f 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,4 +1,4 @@ -import { Resolver, Mutation, Args, ResolveField, Parent } from '@nestjs/graphql'; +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'; @@ -8,6 +8,8 @@ import { CreateStudyDownloadPipe } from '../pipes/study-download-request-create. import { CreateStudyDownloadRequest } from '../dtos/study-download-request-create.dto'; 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'; @UseGuards(JwtAuthGuard, OrganizationGuard) @Resolver(() => StudyDownloadRequest) @@ -22,6 +24,13 @@ export class StudyDownloadRequestResolver { return this.studyDownloadService.createDownloadRequest(downloadRequest, organization); } + @Query(() => [StudyDownloadRequest]) + async getStudyDownloads( + @Args('study', { type: () => ID }, StudyPipe) study: Study + ): Promise { + return this.studyDownloadService.getStudyDownloads(study); + } + @ResolveField(() => String) async entryZip(@Parent() downloadRequest: StudyDownloadRequest): Promise { return this.studyDownloadService.getEntryZipUrl(downloadRequest); 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 60da0e3..889f726 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 @@ -14,6 +14,7 @@ import { TagFieldType } from '../../tag/models/tag-field.model'; 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'; @Injectable() export class StudyDownloadService { @@ -96,6 +97,10 @@ export class StudyDownloadService { return request; } + async getStudyDownloads(study: Study): Promise { + return this.downloadRequestModel.find({ study: study._id }); + } + /** * Handles generating the CSV for the tag data. This approach is a sub-optimal one. * From 8328b0a33b490b8ffce4b24ce395fedcb0d8b719 Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 14:29:32 -0400 Subject: [PATCH 14/17] Add field resolvers --- packages/client/src/graphql/graphql.ts | 3 +++ packages/client/src/graphql/study/study.graphql | 17 ++++++++++++++++- packages/client/src/graphql/study/study.ts | 17 ++++++++++++++++- .../study-download-request.resolver.ts | 17 ++++++++++++++++- .../services/study-download-request.service.ts | 14 +++++++++++++- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 9d1f639..76f50a7 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -588,6 +588,9 @@ export type StudyDownloadRequest = { date: Scalars['DateTime']['output']; entryZip: Scalars['String']['output']; status: Scalars['String']['output']; + study: Study; + tagCSV: Scalars['String']['output']; + taggedEntries: Scalars['String']['output']; }; export type StudyPermissionModel = { diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql index 7d90748..dd855af 100644 --- a/packages/client/src/graphql/study/study.graphql +++ b/packages/client/src/graphql/study/study.graphql @@ -39,6 +39,21 @@ query studyExists($name: String!, $project: ID!) { query getStudyDownloads($study: ID!) { getStudyDownloads(study: $study) { date, - status + status, + entryZip, + tagCSV, + taggedEntries, + study { + _id + name + description + instructions + project + tagsPerEntry + tagSchema { + dataSchema + uiSchema + } + } } } diff --git a/packages/client/src/graphql/study/study.ts b/packages/client/src/graphql/study/study.ts index 0ed71e5..50a8ce1 100644 --- a/packages/client/src/graphql/study/study.ts +++ b/packages/client/src/graphql/study/study.ts @@ -39,7 +39,7 @@ export type GetStudyDownloadsQueryVariables = Types.Exact<{ }>; -export type GetStudyDownloadsQuery = { __typename?: 'Query', getStudyDownloads: Array<{ __typename?: 'StudyDownloadRequest', date: any, status: string }> }; +export type GetStudyDownloadsQuery = { __typename?: 'Query', getStudyDownloads: Array<{ __typename?: 'StudyDownloadRequest', date: any, status: string, entryZip: string, tagCSV: string, taggedEntries: string, study: { __typename?: 'Study', _id: string, name: string, description: string, instructions: string, project: string, tagsPerEntry: number, tagSchema: { __typename?: 'TagSchema', dataSchema: any, uiSchema: any } } }> }; export const FindStudiesDocument = gql` @@ -198,6 +198,21 @@ export const GetStudyDownloadsDocument = gql` getStudyDownloads(study: $study) { date status + entryZip + tagCSV + taggedEntries + study { + _id + name + description + instructions + project + tagsPerEntry + tagSchema { + dataSchema + uiSchema + } + } } } `; 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 f8e871f..eaed772 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 @@ -14,7 +14,7 @@ import { Study } from '../../study/study.model'; @UseGuards(JwtAuthGuard, OrganizationGuard) @Resolver(() => StudyDownloadRequest) export class StudyDownloadRequestResolver { - constructor(private readonly studyDownloadService: StudyDownloadService) {} + constructor(private readonly studyDownloadService: StudyDownloadService, private readonly studyPipe: StudyPipe) {} @Mutation(() => StudyDownloadRequest) async createStudyDownload( @@ -35,4 +35,19 @@ export class StudyDownloadRequestResolver { async entryZip(@Parent() downloadRequest: StudyDownloadRequest): Promise { return this.studyDownloadService.getEntryZipUrl(downloadRequest); } + + @ResolveField(() => Study) + async study(@Parent() downloadRequest: StudyDownloadRequest): Promise { + return this.studyPipe.transform(downloadRequest.study); + } + + @ResolveField(() => String) + async tagCSV(@Parent() downloadRequest: StudyDownloadRequest): Promise { + return this.studyDownloadService.getTagCSVUrl(downloadRequest); + } + + @ResolveField(() => String) + async taggedEntries(@Parent() downloadRequest: StudyDownloadRequest): Promise { + return this.studyDownloadService.getTaggedEntriesUrl(downloadRequest); + } } 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 889f726..2ee345b 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 @@ -157,12 +157,24 @@ export class StudyDownloadService { } async getEntryZipUrl(downloadRequest: StudyDownloadRequest): Promise { + return this.getSignedURL(downloadRequest, downloadRequest.entryZIPLocation!); + } + + async getTagCSVUrl(downloadRequest: StudyDownloadRequest): Promise { + return this.getSignedURL(downloadRequest, downloadRequest.tagCSVLocation!); + } + + async getTaggedEntriesUrl(downloadRequest: StudyDownloadRequest): Promise { + return this.getSignedURL(downloadRequest, downloadRequest.taggedEntriesZipLocation!); + } + + private async getSignedURL(downloadRequest: StudyDownloadRequest, location: string): Promise { const bucket = await this.bucketFactory.getBucket(downloadRequest.organization); if (!bucket) { throw new Error(`Bucket not found for organization ${downloadRequest.organization}`); } return bucket.getSignedUrl( - downloadRequest.entryZIPLocation!, + location, BucketObjectAction.READ, new Date(Date.now() + this.expiration) ) From 12b3c7524832fa8f298c6d83843215df05cb7729 Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 14:49:00 -0400 Subject: [PATCH 15/17] Ability to download entries, tag csv, and tagged entries --- .../client/public/locales/en/translation.json | 4 + .../client/src/graphql/study/study.graphql | 1 + packages/client/src/graphql/study/study.ts | 3 +- .../src/pages/studies/StudyDownloads.tsx | 106 +++++++++++++++++- 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/client/public/locales/en/translation.json b/packages/client/public/locales/en/translation.json index 0905cc9..e427bb1 100644 --- a/packages/client/public/locales/en/translation.json +++ b/packages/client/public/locales/en/translation.json @@ -144,6 +144,10 @@ "tagView": { "originalEntry": "Original Entry", "export": "Export" + }, + "studyDownload": { + "csv": "Tag CSV", + "taggedEntries": "Entries Tagged" } }, "errors": { diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql index dd855af..2aeb2be 100644 --- a/packages/client/src/graphql/study/study.graphql +++ b/packages/client/src/graphql/study/study.graphql @@ -38,6 +38,7 @@ query studyExists($name: String!, $project: ID!) { query getStudyDownloads($study: ID!) { getStudyDownloads(study: $study) { + _id, date, status, entryZip, diff --git a/packages/client/src/graphql/study/study.ts b/packages/client/src/graphql/study/study.ts index 50a8ce1..26ca11c 100644 --- a/packages/client/src/graphql/study/study.ts +++ b/packages/client/src/graphql/study/study.ts @@ -39,7 +39,7 @@ export type GetStudyDownloadsQueryVariables = Types.Exact<{ }>; -export type GetStudyDownloadsQuery = { __typename?: 'Query', getStudyDownloads: Array<{ __typename?: 'StudyDownloadRequest', date: any, status: string, entryZip: string, tagCSV: string, taggedEntries: string, study: { __typename?: 'Study', _id: string, name: string, description: string, instructions: string, project: string, tagsPerEntry: number, tagSchema: { __typename?: 'TagSchema', dataSchema: any, uiSchema: any } } }> }; +export type GetStudyDownloadsQuery = { __typename?: 'Query', getStudyDownloads: Array<{ __typename?: 'StudyDownloadRequest', _id: string, date: any, status: string, entryZip: string, tagCSV: string, taggedEntries: string, study: { __typename?: 'Study', _id: string, name: string, description: string, instructions: string, project: string, tagsPerEntry: number, tagSchema: { __typename?: 'TagSchema', dataSchema: any, uiSchema: any } } }> }; export const FindStudiesDocument = gql` @@ -196,6 +196,7 @@ export type StudyExistsQueryResult = Apollo.QueryResult { + const [studyDownloadRequests, setStudyDownloadRequest] = useState([]); + const { study } = useStudy(); + const { t } = useTranslation(); + const [getDownloadsQuery, getDownloadsResults] = useGetStudyDownloadsLazyQuery(); -export const StudyDownloads: React.FC = () => { + useEffect(() => { + if (!study) { + setStudyDownloadRequest([]); + return; + } + + getDownloadsQuery({ + variables: { + study: study._id + } + }); + }, [study]); + + useEffect(() => { + if (getDownloadsResults.data) { + setStudyDownloadRequest(getDownloadsResults.data.getStudyDownloads); + console.log() + } + }, [getDownloadsResults.data]); + + const columns: GridColDef[] = [ + { + field: 'studyName', + headerName: t('common.study'), + width: 200, + valueGetter: (params) => params.row.study.name + }, + { + field: 'date', + width: 200, + headerName: t('components.datasetDownload.requestDate'), + valueGetter: (params) => t('common.dateFormat', { date: Date.parse(params.row.date) }) + }, + { + field: 'status', + width: 200, + headerName: t('common.status'), + renderCell: (params) => params.value && + }, + { + field: 'entryZip', + width: 200, + headerName: t('components.datasetDownload.entryDownload'), + renderCell: (params) => + params.value && ( + + + + ) + }, + { + field: 'tagCSV', + width: 200, + headerName: t('components.studyDownload.csv'), + renderCell: (params) => + params.value && ( + + + + ) + }, + { + field: 'taggedEntries', + width: 200, + headerName: t('components.studyDownload.taggedEntries'), + renderCell: (params) => + params.value && ( + + + + ) + } + ]; return ( row._id} /> ); }; + +interface StatusViewProps { + status: DownloadStatus; +} + +const StatusView: React.FC = ({ status }) => { + switch (status) { + case DownloadStatus.Ready: + return ; + case DownloadStatus.InProgress: + return ; + } +}; From 1f81e07f00c790e97b30525b0b62ee0b2a59ef9e Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 15:14:00 -0400 Subject: [PATCH 16/17] Working study download process --- .../client/public/locales/en/translation.json | 9 +++- .../client/src/graphql/study/study.graphql | 8 ++++ packages/client/src/graphql/study/study.ts | 44 ++++++++++++++++++- .../client/src/pages/studies/StudyControl.tsx | 39 +++++++++++++++- .../src/pages/studies/StudyDownloads.tsx | 1 - .../study-download-request.service.ts | 3 ++ 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/client/public/locales/en/translation.json b/packages/client/public/locales/en/translation.json index e427bb1..26be8c0 100644 --- a/packages/client/public/locales/en/translation.json +++ b/packages/client/public/locales/en/translation.json @@ -28,7 +28,8 @@ "redo": "Redo", "dataset": "Dataset", "status": "Status", - "dateFormat": "{{date, datetime}}" + "dateFormat": "{{date, datetime}}", + "download": "Download" }, "languages": { "en": "English", @@ -147,7 +148,11 @@ }, "studyDownload": { "csv": "Tag CSV", - "taggedEntries": "Entries Tagged" + "taggedEntries": "Entries Tagged", + "downloadStartedSuccess": "Download has started, the download will be available under the study download page", + "downloadFailed": "Could not download study data, please reach out to your administrator", + "downloadTitle": "Study Download Request", + "downloadDescription": "Would you like to download this study? The tag data, any recorded videos, and the original entries will be download, this may take a while, downloads will appear in the study download page when complete" } }, "errors": { diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql index 2aeb2be..5a4f3c6 100644 --- a/packages/client/src/graphql/study/study.graphql +++ b/packages/client/src/graphql/study/study.graphql @@ -58,3 +58,11 @@ query getStudyDownloads($study: ID!) { } } } + +mutation createStudyDownload($downloadRequest: CreateStudyDownloadRequest!) { + createStudyDownload(downloadRequest: $downloadRequest) { + _id, + status, + date + } +} diff --git a/packages/client/src/graphql/study/study.ts b/packages/client/src/graphql/study/study.ts index 26ca11c..e52e29f 100644 --- a/packages/client/src/graphql/study/study.ts +++ b/packages/client/src/graphql/study/study.ts @@ -41,6 +41,13 @@ export type GetStudyDownloadsQueryVariables = Types.Exact<{ export type GetStudyDownloadsQuery = { __typename?: 'Query', getStudyDownloads: Array<{ __typename?: 'StudyDownloadRequest', _id: string, date: any, status: string, entryZip: string, tagCSV: string, taggedEntries: string, study: { __typename?: 'Study', _id: string, name: string, description: string, instructions: string, project: string, tagsPerEntry: number, tagSchema: { __typename?: 'TagSchema', dataSchema: any, uiSchema: any } } }> }; +export type CreateStudyDownloadMutationVariables = Types.Exact<{ + downloadRequest: Types.CreateStudyDownloadRequest; +}>; + + +export type CreateStudyDownloadMutation = { __typename?: 'Mutation', createStudyDownload: { __typename?: 'StudyDownloadRequest', _id: string, status: string, date: any } }; + export const FindStudiesDocument = gql` query findStudies($project: ID!) { @@ -244,4 +251,39 @@ export function useGetStudyDownloadsLazyQuery(baseOptions?: Apollo.LazyQueryHook } export type GetStudyDownloadsQueryHookResult = ReturnType; export type GetStudyDownloadsLazyQueryHookResult = ReturnType; -export type GetStudyDownloadsQueryResult = Apollo.QueryResult; \ No newline at end of file +export type GetStudyDownloadsQueryResult = Apollo.QueryResult; +export const CreateStudyDownloadDocument = gql` + mutation createStudyDownload($downloadRequest: CreateStudyDownloadRequest!) { + createStudyDownload(downloadRequest: $downloadRequest) { + _id + status + date + } +} + `; +export type CreateStudyDownloadMutationFn = Apollo.MutationFunction; + +/** + * __useCreateStudyDownloadMutation__ + * + * To run a mutation, you first call `useCreateStudyDownloadMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateStudyDownloadMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createStudyDownloadMutation, { data, loading, error }] = useCreateStudyDownloadMutation({ + * variables: { + * downloadRequest: // value for 'downloadRequest' + * }, + * }); + */ +export function useCreateStudyDownloadMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateStudyDownloadDocument, options); + } +export type CreateStudyDownloadMutationHookResult = ReturnType; +export type CreateStudyDownloadMutationResult = Apollo.MutationResult; +export type CreateStudyDownloadMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/packages/client/src/pages/studies/StudyControl.tsx b/packages/client/src/pages/studies/StudyControl.tsx index 8daa93f..da221af 100644 --- a/packages/client/src/pages/studies/StudyControl.tsx +++ b/packages/client/src/pages/studies/StudyControl.tsx @@ -1,14 +1,15 @@ -import { Typography, Box } from '@mui/material'; +import { Typography, Box, IconButton } from '@mui/material'; import { useStudy } from '../../context/Study.context'; import { DataGrid, GridColDef, GridRowId } from '@mui/x-data-grid'; import DeleteIcon from '@mui/icons-material/DeleteOutlined'; import { GridActionsCellItem } from '@mui/x-data-grid-pro'; import { Study } from '../../graphql/graphql'; -import { useDeleteStudyMutation } from '../../graphql/study/study'; +import { useCreateStudyDownloadMutation, useDeleteStudyMutation } from '../../graphql/study/study'; import { useEffect } from 'react'; import { useConfirmation } from '../../context/Confirmation.context'; import { useTranslation } from 'react-i18next'; import { useSnackbar } from '../../context/Snackbar.context'; +import { Download } from '@mui/icons-material'; export const StudyControl: React.FC = () => { const { studies, updateStudies } = useStudy(); @@ -18,6 +19,8 @@ export const StudyControl: React.FC = () => { const { t } = useTranslation(); const { pushSnackbarMessage } = useSnackbar(); + const [createDownloadMutation, createDownloadResults] = useCreateStudyDownloadMutation(); + const handleDelete = async (id: GridRowId) => { // Execute delete mutation confirmation.pushConfirmationRequest({ @@ -40,6 +43,32 @@ export const StudyControl: React.FC = () => { } }, [deleteStudyResults.called, deleteStudyResults.data, deleteStudyResults.error]); + const handleDownloadRequest = (study: Study) => { + confirmation.pushConfirmationRequest({ + title: t('components.studyDownload.downloadTitle'), + message: t('components.studyDownload.downloadDescription'), + onConfirm: () => { + createDownloadMutation({ + variables: { + downloadRequest: { + study: study._id + } + } + }); + }, + onCancel: () => {} + }); + }; + + // Share the results with the user + useEffect(() => { + if (createDownloadResults.data) { + pushSnackbarMessage(t('components.studyDownload.downloadStartedSuccess'), 'success'); + } else if (createDownloadResults.error) { + pushSnackbarMessage(t('components.studyDownload.downloadFailed'), 'error'); + } + }, [createDownloadResults.data, createDownloadResults.error]); + const columns: GridColDef[] = [ { field: 'name', @@ -53,6 +82,12 @@ export const StudyControl: React.FC = () => { width: 500, editable: false }, + { + field: 'download', + headerName: t('common.download'), + width: 200, + renderCell: (params) => handleDownloadRequest(params.row)}> + }, { field: 'delete', type: 'actions', diff --git a/packages/client/src/pages/studies/StudyDownloads.tsx b/packages/client/src/pages/studies/StudyDownloads.tsx index e1d2f86..ea79dfd 100644 --- a/packages/client/src/pages/studies/StudyDownloads.tsx +++ b/packages/client/src/pages/studies/StudyDownloads.tsx @@ -30,7 +30,6 @@ export const StudyDownloads: React.FC = () => { useEffect(() => { if (getDownloadsResults.data) { setStudyDownloadRequest(getDownloadsResults.data.getStudyDownloads); - console.log() } }, [getDownloadsResults.data]); 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 2ee345b..ca4fbdd 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 @@ -127,6 +127,9 @@ export class StudyDownloadService { for (const tag of tags) { const tagFields: any = {}; + // Add basic meta-fields + tagFields['prompt'] = (await this.entryService.find(tag.entry))!.bucketLocation.split('/').pop(); + for (const field of tag.data!) { // For video fields, each entry is represented by the filename From d7d508ca3b5fc024d4053008cc8449a87d03d41a Mon Sep 17 00:00:00 2001 From: cbolles Date: Fri, 3 May 2024 15:14:31 -0400 Subject: [PATCH 17/17] Fix formatting --- .../client/src/pages/studies/StudyControl.tsx | 6 ++- .../src/pages/studies/StudyDownloads.tsx | 8 +--- .../dataset-download-request-create.dto.ts | 10 ++++- .../dtos/study-download-request-create.dto.ts | 30 +++++++------ .../study-download-request-create.pipe.ts | 4 +- .../study-download-request.resolver.ts | 4 +- .../services/download-request.service.ts | 3 +- .../study-download-request.service.ts | 44 +++++++++---------- .../src/entry/services/entry.service.ts | 8 +++- .../tag/services/video-field-inter.service.ts | 2 +- 10 files changed, 66 insertions(+), 53 deletions(-) diff --git a/packages/client/src/pages/studies/StudyControl.tsx b/packages/client/src/pages/studies/StudyControl.tsx index da221af..8bb6c03 100644 --- a/packages/client/src/pages/studies/StudyControl.tsx +++ b/packages/client/src/pages/studies/StudyControl.tsx @@ -86,7 +86,11 @@ export const StudyControl: React.FC = () => { field: 'download', headerName: t('common.download'), width: 200, - renderCell: (params) => handleDownloadRequest(params.row)}> + renderCell: (params) => ( + handleDownloadRequest(params.row)}> + + + ) }, { field: 'delete', diff --git a/packages/client/src/pages/studies/StudyDownloads.tsx b/packages/client/src/pages/studies/StudyDownloads.tsx index ea79dfd..a78a66d 100644 --- a/packages/client/src/pages/studies/StudyDownloads.tsx +++ b/packages/client/src/pages/studies/StudyDownloads.tsx @@ -87,13 +87,7 @@ export const StudyDownloads: React.FC = () => { } ]; - return ( - row._id} - /> - ); + return row._id} />; }; interface StatusViewProps { diff --git a/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts b/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts index 2ea3183..0bc54e2 100644 --- a/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts +++ b/packages/server/src/download-request/dtos/dataset-download-request-create.dto.ts @@ -4,7 +4,15 @@ import { DatasetDownloadRequest } from '../models/dataset-download-request.model @InputType() export class CreateDatasetDownloadRequest extends OmitType( DatasetDownloadRequest, - ['_id', 'date', 'status', 'entryZIPLocation', 'bucketLocation', 'entryJSONLocation', 'webhookPayloadLocation'] as const, + [ + '_id', + 'date', + 'status', + 'entryZIPLocation', + 'bucketLocation', + 'entryJSONLocation', + 'webhookPayloadLocation' + ] as const, InputType ) { @Field(() => ID) diff --git a/packages/server/src/download-request/dtos/study-download-request-create.dto.ts b/packages/server/src/download-request/dtos/study-download-request-create.dto.ts index 5ea3340..773fac6 100644 --- a/packages/server/src/download-request/dtos/study-download-request-create.dto.ts +++ b/packages/server/src/download-request/dtos/study-download-request-create.dto.ts @@ -2,19 +2,23 @@ import { Field, ID, InputType, OmitType } from '@nestjs/graphql'; import { StudyDownloadRequest } from '../models/study-download-request.model'; @InputType() -export class CreateStudyDownloadRequest extends OmitType(StudyDownloadRequest, [ - '_id', - 'date', - 'status', - 'tagCSVLocation', - 'entryZIPLocation', - 'bucketLocation', - 'entryZIPLocation', - 'webhookPayloadLocation', - 'taggedEntriesJSONLocation', - 'taggedEntriesZipLocation', - 'taggedEntryWebhookPayloadLocation' -] as const, InputType) { +export class CreateStudyDownloadRequest extends OmitType( + StudyDownloadRequest, + [ + '_id', + 'date', + 'status', + 'tagCSVLocation', + 'entryZIPLocation', + 'bucketLocation', + 'entryZIPLocation', + 'webhookPayloadLocation', + 'taggedEntriesJSONLocation', + 'taggedEntriesZipLocation', + 'taggedEntryWebhookPayloadLocation' + ] as const, + InputType +) { @Field(() => ID) study: string; } diff --git a/packages/server/src/download-request/pipes/study-download-request-create.pipe.ts b/packages/server/src/download-request/pipes/study-download-request-create.pipe.ts index ad0e788..2fc44ef 100644 --- a/packages/server/src/download-request/pipes/study-download-request-create.pipe.ts +++ b/packages/server/src/download-request/pipes/study-download-request-create.pipe.ts @@ -3,7 +3,9 @@ import { StudyService } from '../../study/study.service'; import { CreateStudyDownloadRequest } from '../dtos/study-download-request-create.dto'; @Injectable() -export class CreateStudyDownloadPipe implements PipeTransform> { +export class CreateStudyDownloadPipe + implements PipeTransform> +{ constructor(private readonly studyService: StudyService) {} async transform(value: CreateStudyDownloadRequest): Promise { 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 eaed772..c2aa363 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 @@ -25,9 +25,7 @@ export class StudyDownloadRequestResolver { } @Query(() => [StudyDownloadRequest]) - async getStudyDownloads( - @Args('study', { type: () => ID }, StudyPipe) study: Study - ): Promise { + async getStudyDownloads(@Args('study', { type: () => ID }, StudyPipe) study: Study): Promise { return this.studyDownloadService.getStudyDownloads(study); } diff --git a/packages/server/src/download-request/services/download-request.service.ts b/packages/server/src/download-request/services/download-request.service.ts index 0dc522d..890be9a 100644 --- a/packages/server/src/download-request/services/download-request.service.ts +++ b/packages/server/src/download-request/services/download-request.service.ts @@ -5,7 +5,6 @@ import { Bucket } from '../../bucket/bucket'; import { JOB_PROVIDER } from '../../gcp/providers/job.provider'; import { JobsClient } from '@google-cloud/run'; - export interface ZipJobRequest { /** Where to put the entry JSON file in the bucket */ entryJSONLocation: string; @@ -62,7 +61,7 @@ export class DownloadRequestService { const entryLocations = request.entries.map((entry) => `${mountPoint}/${entry.bucketLocation}`); // Convert the list to a string for saving - const entryContent: string = JSON.stringify({ 'entries': entryLocations }); + const entryContent: string = JSON.stringify({ entries: entryLocations }); // Now upload the generated JSON file with the entry locations into the bucket await request.bucket.writeText(request.entryJSONLocation, entryContent); 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 ca4fbdd..0e5c9fb 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 @@ -20,7 +20,6 @@ import { Study } from 'src/study/study.model'; export class StudyDownloadService { private readonly expiration = this.configService.getOrThrow('entry.signedURLExpiration'); - constructor( @InjectModel(StudyDownloadRequest.name) private readonly downloadRequestModel: Model, @@ -29,11 +28,13 @@ export class StudyDownloadService { private readonly bucketFactory: BucketFactory, private readonly configService: ConfigService, private readonly tagService: TagService, - private readonly videoFieldService: VideoFieldService, + private readonly videoFieldService: VideoFieldService ) {} - - async createDownloadRequest(downloadRequest: CreateStudyDownloadRequest, organization: Organization): Promise { + async createDownloadRequest( + downloadRequest: CreateStudyDownloadRequest, + organization: Organization + ): Promise { let request = await this.downloadRequestModel.create({ ...downloadRequest, date: new Date(), @@ -131,7 +132,6 @@ export class StudyDownloadService { tagFields['prompt'] = (await this.entryService.find(tag.entry))!.bucketLocation.split('/').pop(); for (const field of tag.data!) { - // For video fields, each entry is represented by the filename if (field.type == TagFieldType.VIDEO_RECORD) { const videoField = (await this.videoFieldService.find(field.data))!; @@ -143,7 +143,6 @@ export class StudyDownloadService { } else { tagFields[`${field.name}`] = field.data; } - } converted.push(tagFields); } @@ -176,25 +175,22 @@ export class StudyDownloadService { if (!bucket) { throw new Error(`Bucket not found for organization ${downloadRequest.organization}`); } - return bucket.getSignedUrl( - location, - BucketObjectAction.READ, - new Date(Date.now() + this.expiration) - ) + return bucket.getSignedUrl(location, BucketObjectAction.READ, new Date(Date.now() + this.expiration)); } /** * TODO: Improve the CSV process, need a better method to determine the headers and handle default values */ private convertToCSV(arr: any[]): string { - const array = [Object.keys(arr[0])].concat(arr) + const array = [Object.keys(arr[0])].concat(arr); - return array.map(it => { - return Object.values(it).toString() - }).join('\n') + return array + .map((it) => { + return Object.values(it).toString(); + }) + .join('\n'); } - /** * Get the entries taged as part of the study */ @@ -207,12 +203,14 @@ export class StudyDownloadService { entryIDs = Array.from(new Set(entryIDs)); // Get all the entries - return Promise.all(entryIDs.map(async (id) => { - const entry = await this.entryService.find(id); - if (!entry) { - throw new Error(`Invalid id for entry: ${id}`); - } - return entry; - })); + return Promise.all( + entryIDs.map(async (id) => { + const entry = await this.entryService.find(id); + if (!entry) { + throw new Error(`Invalid id for entry: ${id}`); + } + return entry; + }) + ); } } diff --git a/packages/server/src/entry/services/entry.service.ts b/packages/server/src/entry/services/entry.service.ts index 1adcc33..d1fe70b 100644 --- a/packages/server/src/entry/services/entry.service.ts +++ b/packages/server/src/entry/services/entry.service.ts @@ -24,7 +24,13 @@ export class EntryService { return this.entryModel.findOne({ _id: entryID }); } - async create(entryCreate: EntryCreate, dataset: Dataset, user: TokenPayload, isTraining: boolean, signLabRecorded?: SignLabRecorded): Promise { + async create( + entryCreate: EntryCreate, + dataset: Dataset, + user: TokenPayload, + isTraining: boolean, + signLabRecorded?: SignLabRecorded + ): Promise { // Make the entry, note that training entries are not associated with a dataset return this.entryModel.create({ ...entryCreate, diff --git a/packages/server/src/tag/services/video-field-inter.service.ts b/packages/server/src/tag/services/video-field-inter.service.ts index e41e4c9..3251729 100644 --- a/packages/server/src/tag/services/video-field-inter.service.ts +++ b/packages/server/src/tag/services/video-field-inter.service.ts @@ -97,7 +97,7 @@ export class VideoFieldIntermediateService { { entryID: 'TODO: Generate entry ID', contentType: 'video/webm', - meta: {}, + meta: {} }, dataset, user,