Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@apollo/subgraph": "^2.4.12",
"@google-cloud/storage": "^7.7.0",
"@jsonforms/core": "^3.2.1",
"@nestjs/apollo": "^12.0.7",
"@nestjs/axios": "^3.0.1",
"@nestjs/common": "^9.0.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/study/study.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose';
import { ObjectType, Field, ID } from '@nestjs/graphql';
import mongoose, { Document } from 'mongoose';
import JSON from 'graphql-type-json';
import { Schema as JSONSchema } from 'jsonschema';
import { Layout, JsonSchema as JSONSchema } from '@jsonforms/core';

/** Definition for the tag in JSON schema */
@Schema()
Expand All @@ -14,7 +14,7 @@ export class TagSchema {

@Prop({ type: mongoose.Schema.Types.Mixed, required: true })
@Field(() => JSON)
uiSchema: any;
uiSchema: Layout;
}

const TagSchemaSchema = SchemaFactory.createForClass(TagSchema);
Expand Down
6 changes: 4 additions & 2 deletions packages/server/src/tag/resolvers/tag.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ export class TagResolver {
@Mutation(() => Boolean)
async completeTag(
@Args('tag', { type: () => ID }, TagPipe) tag: Tag,
@Args('data', { type: () => JSON }) data: any
@Args('data', { type: () => JSON }) data: any,
@TokenContext() user: TokenPayload
): Promise<boolean> {
// TODO: Add user context and verify the correct user has completed the tag
await this.tagService.complete(tag, data);
const study = await this.studyPipe.transform(tag.study);
await this.tagService.complete(tag, data, study, user);
return true;
}

Expand Down
46 changes: 46 additions & 0 deletions packages/server/src/tag/services/tag-transformer.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { Study } from '../../study/study.model';
import { FieldTransformerFactory } from '../transformers/field-transformer-factory';
import { TokenPayload } from '../../jwt/token.dto';

@Injectable()
export class TagTransformer {
constructor(private readonly fieldTransformerFactory: FieldTransformerFactory) {}

/**
* Transforms the tag data. Takes in the whole tag and produces the modified
* tag data.
*/
async transformTagData(data: any, study: Study, user: TokenPayload): Promise<any> {
const transformedData: { [property: string]: any } = {};

const schema = study.tagSchema.dataSchema;
const uischema = study.tagSchema.uiSchema;

if (!schema.properties) {
return data;
}

for (const field in schema.properties) {
// Get the schema and ui schema for the field
const fieldSchema = schema.properties[field];
const fieldUiSchema = uischema.elements.find((element) => (element as any).scope === `#/properties/${field}`);

// If the field UI schema is not found, throw an error
if (!fieldUiSchema) {
throw new Error(`Could not find ui schema for field ${field}`);
}

// Try to get the transformer for the field
const transformer = this.fieldTransformerFactory.getTransformer(fieldUiSchema, fieldSchema);

// Apply the transformation if present, otherwise just return the data
const transformed = transformer
? await transformer.transformField(data[field], fieldUiSchema, fieldSchema, user)
: data[field];
transformedData[field] = transformed;
}

return transformedData;
}
}
12 changes: 9 additions & 3 deletions packages/server/src/tag/services/tag.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { Study } from '../../study/study.model';
import { Entry } from '../../entry/models/entry.model';
import { StudyService } from '../../study/study.service';
import { MongooseMiddlewareService } from '../../shared/service/mongoose-callback.service';
import { TagTransformer } from './tag-transformer.service';
import { TokenPayload } from '../../jwt/token.dto';

@Injectable()
export class TagService {
constructor(
@InjectModel(Tag.name) private readonly tagModel: Model<Tag>,
private readonly studyService: StudyService,
middlewareService: MongooseMiddlewareService
middlewareService: MongooseMiddlewareService,
private readonly tagTransformService: TagTransformer
) {
// Subscribe to study delete events
middlewareService.register(Study.name, 'deleteOne', async (study: Study) => {
Expand Down Expand Up @@ -99,7 +102,7 @@ export class TagService {
}

/** Store the data and mark the tag as complete */
async complete(tag: Tag, data: any): Promise<void> {
async complete(tag: Tag, data: any, study: Study, user: TokenPayload): Promise<void> {
// If the tag is already complete, it cannot be saved again
if (tag.complete) {
throw new BadRequestException(`Cannot re-save tag data`);
Expand All @@ -111,8 +114,11 @@ export class TagService {
throw new BadRequestException(`Tag data does not match study schema`);
}

// Handle any transformations
const transformed = await this.tagTransformService.transformTagData(data, study, user);

// Save the tag information and mark the tag as complete
await this.tagModel.findOneAndUpdate({ _id: tag._id }, { $set: { data, complete: true } });
await this.tagModel.findOneAndUpdate({ _id: tag._id }, { $set: { data: transformed, complete: true } });
}

async isEntryEnabled(study: Study, entry: Entry) {
Expand Down
45 changes: 44 additions & 1 deletion packages/server/src/tag/services/video-field.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { StudyService } from '../../study/study.service';
import { ConfigService } from '@nestjs/config';
import { GCP_STORAGE_PROVIDER } from '../../gcp/providers/storage.provider';
import { Storage, Bucket } from '@google-cloud/storage';
import { Entry } from '../../entry/models/entry.model';
import { EntryService } from '../../entry/services/entry.service';
import { DatasetPipe } from '../../dataset/pipes/dataset.pipe';
import { TokenPayload } from '../../jwt/token.dto';

@Injectable()
export class VideoFieldService {
Expand All @@ -20,7 +24,9 @@ export class VideoFieldService {
@InjectModel(VideoField.name) private readonly videoFieldModel: Model<VideoFieldDocument>,
private readonly studyService: StudyService,
private readonly configService: ConfigService,
@Inject(GCP_STORAGE_PROVIDER) private readonly storage: Storage
@Inject(GCP_STORAGE_PROVIDER) private readonly storage: Storage,
private readonly entryService: EntryService,
private readonly datasetPipe: DatasetPipe
) {}

async saveVideoField(tag: Tag, field: string, index: number): Promise<VideoField> {
Expand Down Expand Up @@ -62,6 +68,43 @@ export class VideoFieldService {
return url;
}

/**
* Move the video itself to the permanent storage location and create the
* cooresponding entry.
*/
async markComplete(videoFieldID: string, datasetID: string, user: TokenPayload): Promise<Entry> {
const videoField = await this.videoFieldModel.findById(videoFieldID);
if (!videoField) {
throw new BadRequestException(`Video field ${videoFieldID} not found`);
}

const dataset = await this.datasetPipe.transform(datasetID);

// Make the entry
const entry = await this.entryService.create(
{
entryID: 'TODO: Generate entry ID',
contentType: 'video/webm',
meta: {}
},
dataset,
user
);

// Move the video to the permanent location
const source = this.bucket.file(videoField.bucketLocation);
const newLocation = `${dataset.bucketPrefix}/${entry._id}.webm`;
await source.move(newLocation);
await this.entryService.setBucketLocation(entry, newLocation);
entry.bucketLocation = newLocation;

// Remove the video field
await this.videoFieldModel.deleteOne({ _id: videoField._id });

// Return the completed entry
return entry;
}

private getVideoFieldBucketLocation(tagID: string, field: string, index: number): string {
return `${this.bucketPrefix}/${tagID}/${field}/${index}.${this.videoRecordFileType}`;
}
Expand Down
18 changes: 16 additions & 2 deletions packages/server/src/tag/tag.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { VideoField, VideoFieldSchema } from './models/video-field.model';
import { VideoFieldService } from './services/video-field.service';
import { VideoFieldResolver } from './resolvers/video-field.resolver';
import { GcpModule } from '../gcp/gcp.module';
import { TagTransformer } from './services/tag-transformer.service';
import { FieldTransformerFactory } from './transformers/field-transformer-factory';
import { VideoFieldTransformer } from './transformers/video-field-transformer';
import { DatasetModule } from '../dataset/dataset.module';

@Module({
imports: [
Expand All @@ -23,8 +27,18 @@ import { GcpModule } from '../gcp/gcp.module';
EntryModule,
SharedModule,
PermissionModule,
GcpModule
GcpModule,
DatasetModule
],
providers: [TagService, TagResolver, TagPipe, VideoFieldService, VideoFieldResolver]
providers: [
TagService,
TagResolver,
TagPipe,
VideoFieldService,
VideoFieldResolver,
TagTransformer,
FieldTransformerFactory,
VideoFieldTransformer
]
})
export class TagModule {}
29 changes: 29 additions & 0 deletions packages/server/src/tag/transformers/field-transformer-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { JsonSchema, UISchemaElement } from '@jsonforms/core';
import { Injectable } from '@nestjs/common';
import { FieldTransformer, FieldTransformerTest, NOT_APPLICABLE } from './field-transformer';
import { VideoFieldTransformer, VideoFieldTransformerTest } from './video-field-transformer';

type FieldTransformerOptions = { tester: FieldTransformerTest; transformer: FieldTransformer };

@Injectable()
export class FieldTransformerFactory {
private readonly transformers: FieldTransformerOptions[] = [
{ tester: VideoFieldTransformerTest, transformer: this.videoFieldTransformer }
];

constructor(private readonly videoFieldTransformer: VideoFieldTransformer) {}

/** Get the transformer for the given field */
getTransformer(uischema: UISchemaElement, schema: JsonSchema): FieldTransformer | null {
// Run the testers, discard non-matches, and sort by priority
const transformers = this.transformers
.filter(({ tester }) => tester(uischema, schema) !== NOT_APPLICABLE)
.sort((a, b) => b.tester(uischema, schema) - a.tester(uischema, schema));

// If there are any matches, return the first one
if (transformers.length > 0) {
return transformers[0].transformer;
}
return null;
}
}
25 changes: 25 additions & 0 deletions packages/server/src/tag/transformers/field-transformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { JsonSchema, UISchemaElement } from '@jsonforms/core';
import { TokenPayload } from '../../jwt/token.dto';

/**
* A field transformer handles converting and operating on fields of a tag.
* It handles adding in additional information, update intermediate data,
* and ensuring that the data meets any additional formatting requirements.
*/
export interface FieldTransformer {
transformField(field: any, uischema: UISchemaElement, schema: JsonSchema, user: TokenPayload): Promise<any>;
}

/**
* Tests to see if a given field should be transformed. Each `FieldTransformer`
* has a cooresponding `FieldTransformerTest` that determines if the field
* should be transformed.
*
* The rank is used to determine if one transformer should be used over another.
* The larger the rank, the higher the priority.
*
* This is similar to the `RankedTester` interface in `@jsonforms/core`.
*/
export type FieldTransformerTest = (uischema: UISchemaElement, schema: JsonSchema) => number;
/** Number returned when a transformer does not apply to a given field */
export const NOT_APPLICABLE = -1;
38 changes: 38 additions & 0 deletions packages/server/src/tag/transformers/video-field-transformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { FieldTransformer } from './field-transformer';
import { JsonSchema, UISchemaElement } from '@jsonforms/core';
import { VideoFieldService } from '../services/video-field.service';
import { TokenPayload } from '../../jwt/token.dto';

@Injectable()
export class VideoFieldTransformer implements FieldTransformer {
constructor(private readonly videoFieldService: VideoFieldService) {}

async transformField(
data: string[],
uischema: UISchemaElement,
_schema: JsonSchema,
user: TokenPayload
): Promise<any> {
const datasetID = uischema.options?.dataset;
if (!datasetID) {
throw new BadRequestException('Dataset ID not provided');
}

const videoFields = await Promise.all(
data.map(async (videoFieldId) => {
const entry = await this.videoFieldService.markComplete(videoFieldId, datasetID, user);
return entry._id;
})
);

return videoFields;
}
}

export const VideoFieldTransformerTest = (uischema: UISchemaElement, _schema: JsonSchema) => {
if (uischema.options && uischema.options.customType && uischema.options.customType === 'video') {
return 10;
}
return -1;
};