diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bfec1cc1..24b21c7e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -39,6 +39,7 @@ model file_submission { create_utc_timestamp DateTime @db.Timestamp(6) update_user_id String @db.VarChar(200) update_utc_timestamp DateTime @db.Timestamp(6) + data_submitter_email String? @db.VarChar(200) file_error_logs file_error_logs[] file_operation_codes file_operation_codes @relation(fields: [file_operation_code], references: [file_operation_code], onDelete: NoAction, onUpdate: NoAction, map: "file_operation_code_fk") submission_status submission_status_code @relation(fields: [submission_status_code], references: [submission_status_code], onDelete: NoAction, onUpdate: NoAction, map: "submission_status_code_fk") diff --git a/backend/src/file_error_logs/file_error_logs.module.ts b/backend/src/file_error_logs/file_error_logs.module.ts index 510f3f47..17508a2d 100644 --- a/backend/src/file_error_logs/file_error_logs.module.ts +++ b/backend/src/file_error_logs/file_error_logs.module.ts @@ -1,8 +1,9 @@ -import { Module } from '@nestjs/common'; -import { FileErrorLogsService } from './file_error_logs.service'; -import { FileErrorLogsController } from './file_error_logs.controller'; +import { Module } from "@nestjs/common"; +import { FileErrorLogsService } from "./file_error_logs.service"; +import { FileErrorLogsController } from "./file_error_logs.controller"; @Module({ + exports: [FileErrorLogsService], controllers: [FileErrorLogsController], providers: [FileErrorLogsService], }) diff --git a/backend/src/file_parse_and_validation/file_parse_and_validation.module.ts b/backend/src/file_parse_and_validation/file_parse_and_validation.module.ts index c425c295..079c03d7 100644 --- a/backend/src/file_parse_and_validation/file_parse_and_validation.module.ts +++ b/backend/src/file_parse_and_validation/file_parse_and_validation.module.ts @@ -1,13 +1,20 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { FileParseValidateService } from './file_parse_and_validation.service'; -import { AqiApiService } from 'src/aqi_api/aqi_api.service'; -import { FileSubmissionsService } from 'src/file_submissions/file_submissions.service'; -import { ObjectStoreModule } from 'src/objectStore/objectStore.module'; +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { FileParseValidateService } from "./file_parse_and_validation.service"; +import { AqiApiService } from "src/aqi_api/aqi_api.service"; +import { FileSubmissionsService } from "src/file_submissions/file_submissions.service"; +import { NotificationsService } from "src/notifications/notifications.service"; +import { ObjectStoreModule } from "src/objectStore/objectStore.module"; +import { FileErrorLogsModule } from "src/file_error_logs/file_error_logs.module"; @Module({ - providers: [FileParseValidateService, FileSubmissionsService, AqiApiService], + providers: [ + FileParseValidateService, + FileSubmissionsService, + AqiApiService, + NotificationsService, + ], exports: [FileParseValidateService], - imports: [HttpModule, ObjectStoreModule] + imports: [HttpModule, ObjectStoreModule, FileErrorLogsModule], }) export class FileParseValidateModule {} diff --git a/backend/src/file_parse_and_validation/file_parse_and_validation.service.ts b/backend/src/file_parse_and_validation/file_parse_and_validation.service.ts index e5d34ad8..8591e24a 100644 --- a/backend/src/file_parse_and_validation/file_parse_and_validation.service.ts +++ b/backend/src/file_parse_and_validation/file_parse_and_validation.service.ts @@ -14,6 +14,7 @@ import * as path from "path"; import * as csvWriter from "csv-writer"; import fs from "fs"; import { PrismaService } from "nestjs-prisma"; +import { NotificationsService } from "src/notifications/notifications.service"; const visits: FieldVisits = { MinistryContact: "", @@ -146,6 +147,7 @@ export class FileParseValidateService { private prisma: PrismaService, private readonly fileSubmissionsService: FileSubmissionsService, private readonly aqiService: AqiApiService, + private readonly notificationsService: NotificationsService, ) {} async getQueuedFiles() { @@ -599,7 +601,7 @@ export class FileParseValidateService { return dupeCount; } - async localValidation(allRecords, observaionFilePath) { + async localValidation(allRecords, observationFilePath) { let errorLogs = []; for (const [index, record] of allRecords.entries()) { const isoDateTimeRegex = @@ -861,7 +863,7 @@ export class FileParseValidateService { // Do a dry run of the observations const observationsErrors = await this.aqiService.importObservations( - observaionFilePath, + observationFilePath, "dryrun", ); @@ -944,7 +946,9 @@ export class FileParseValidateService { fileName, ); - const uniqueMinistryContacts = Array.from(new Set(allRecords.map(rec => rec.MinistryContact))) + const uniqueMinistryContacts = Array.from( + new Set(allRecords.map((rec) => rec.MinistryContact)), + ); /* * Do the local validation for each section here - if passed then go to the API calls - else create the message/file/email for the errors */ @@ -975,7 +979,7 @@ export class FileParseValidateService { file_name: fileName, original_file_name: originalFileName, file_operation_code: file_operation_code, - ministry_contact: uniqueMinistryContacts.join(', '), + ministry_contact: uniqueMinistryContacts.join(", "), error_log: localValidationResults, create_utc_timestamp: new Date(), }; @@ -983,6 +987,11 @@ export class FileParseValidateService { await this.prisma.file_error_logs.create({ data: file_error_log_data, }); + + await this.notificationsService.sendDataSubmitterNotification( + file_submission_id, + ); + return; } else if (!(await localValidationResults).includes("ERROR")) { await this.fileSubmissionsService.updateFileStatus( diff --git a/backend/src/file_submissions/dto/create-file_submission.dto.ts b/backend/src/file_submissions/dto/create-file_submission.dto.ts index 423a0814..bb5d04dc 100644 --- a/backend/src/file_submissions/dto/create-file_submission.dto.ts +++ b/backend/src/file_submissions/dto/create-file_submission.dto.ts @@ -15,6 +15,7 @@ export class CreateFileSubmissionDto extends PickType(FileSubmissionDto, [ 'active_ind', 'error_log', 'organization_guid', + 'data_submitter_email', 'create_user_id', 'create_utc_timestamp', 'update_user_id', diff --git a/backend/src/file_submissions/dto/file_submission.dto.ts b/backend/src/file_submissions/dto/file_submission.dto.ts index 6a466264..bb528082 100644 --- a/backend/src/file_submissions/dto/file_submission.dto.ts +++ b/backend/src/file_submissions/dto/file_submission.dto.ts @@ -73,6 +73,11 @@ export class FileSubmissionDto { }) organization_guid: string; + @ApiProperty({ + description: 'The data submitter\'s email' + }) + data_submitter_email: string; + @ApiProperty({ description: 'The id of the user that created the record', }) diff --git a/backend/src/file_submissions/file_submissions.service.ts b/backend/src/file_submissions/file_submissions.service.ts index a9d685a2..2991640e 100644 --- a/backend/src/file_submissions/file_submissions.service.ts +++ b/backend/src/file_submissions/file_submissions.service.ts @@ -45,6 +45,7 @@ export class FileSubmissionsService { createFileSubmissionDto.sample_count = 0; createFileSubmissionDto.result_count = 0; createFileSubmissionDto.organization_guid = body.orgGUID; // TODO: change this once BCeID is set up + createFileSubmissionDto.data_submitter_email = body.dataSubmitterEmail; createFileSubmissionDto.create_user_id = body.userID; createFileSubmissionDto.create_utc_timestamp = new Date(); createFileSubmissionDto.update_user_id = body.userID; @@ -68,6 +69,7 @@ export class FileSubmissionsService { active_ind: createFileSubmissionDto.active_ind, error_log: createFileSubmissionDto.error_log, organization_guid: createFileSubmissionDto.organization_guid, + data_submitter_email: createFileSubmissionDto.data_submitter_email, create_user_id: createFileSubmissionDto.create_user_id, create_utc_timestamp: createFileSubmissionDto.create_utc_timestamp, update_user_id: createFileSubmissionDto.update_user_id, @@ -184,6 +186,17 @@ export class FileSubmissionsService { return records; } + async findBySubmissionId(submission_id: string) { + return await this.prisma.file_submission.findUnique({ + where: { + submission_id: submission_id, + }, + include: { + file_error_logs: true, + }, + }); + } + async updateFileStatus(submission_id: string, status: string) { await this.prisma.file_submission.update({ where: { diff --git a/backend/src/notifications/notifications.controller.ts b/backend/src/notifications/notifications.controller.ts index 8a7b473f..cd0e6e3a 100644 --- a/backend/src/notifications/notifications.controller.ts +++ b/backend/src/notifications/notifications.controller.ts @@ -22,30 +22,30 @@ export class NotificationsController { // test route TODO: delete this @Get("send-email/:email") sendEmail(@Param("email") email: string) { - const re = - /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - if (!re.test(String(email).toLowerCase())) { - email = "mtennant@salussystems.com"; - } + // const re = + // /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + // if (!re.test(String(email).toLowerCase())) { + // email = "mtennant@salussystems.com"; + // } - const variables = { - file_name: "test_file.csv", - user_account_name: "MTENNANT", - file_status: "Failed", - errors: "Something went wrong.", - warnings: "", - }; - return this.notificationsService.sendContactNotification(email, variables); + // const variables = { + // file_name: "test_file.csv", + // user_account_name: "MTENNANT", + // file_status: "Failed", + // errors: ["Something went wrong."], + // warnings: [], + // }; + // return this.notificationsService.sendContactNotification(email, variables); } @Post("update-notification") updateNotification( - @Body() userData: { email: string; username: string; enabled: boolean } + @Body() userData: { email: string; username: string; enabled: boolean }, ) { return this.notificationsService.updateNotificationEntry( userData.email, userData.username, - userData.enabled + userData.enabled, ); } @@ -53,7 +53,7 @@ export class NotificationsController { getNotificationStatus(@Body() userData: { email: string; username: string }) { return this.notificationsService.getNotificationStatus( userData.email, - userData.username + userData.username, ); } diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts index c80e3860..b2929c2c 100644 --- a/backend/src/notifications/notifications.module.ts +++ b/backend/src/notifications/notifications.module.ts @@ -2,9 +2,11 @@ import { Module } from "@nestjs/common"; import { NotificationsController } from "./notifications.controller"; import { NotificationsService } from "./notifications.service"; import { HttpModule } from "@nestjs/axios"; +import { FileErrorLogsModule } from "src/file_error_logs/file_error_logs.module"; +import { FileSubmissionsModule } from "src/file_submissions/file_submissions.module"; @Module({ - imports: [HttpModule], + imports: [HttpModule, FileErrorLogsModule, FileSubmissionsModule], controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService], diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts index f79b6f48..ef20a500 100644 --- a/backend/src/notifications/notifications.service.ts +++ b/backend/src/notifications/notifications.service.ts @@ -6,6 +6,8 @@ import { Prisma } from "@prisma/client"; import { PrismaService } from "nestjs-prisma"; import { UpdateNotificationEntryDto } from "./dto/update-notification_entry.dto"; import { EmailTemplate } from "src/types/types"; +import { FileErrorLogsService } from "src/file_error_logs/file_error_logs.service"; +import { FileSubmissionsService } from "src/file_submissions/file_submissions.service"; @Injectable() export class NotificationsService { @@ -13,6 +15,8 @@ export class NotificationsService { constructor( private readonly httpService: HttpService, + private readonly fileErrorLogsService: FileErrorLogsService, + private readonly fileSubmissionsService: FileSubmissionsService, private prisma: PrismaService, ) {} @@ -146,41 +150,32 @@ export class NotificationsService { } /** - * Sends an email to the data submitter. Does not check if notifications are filtered. + * Uses a file submission id to gather information on the completed/rejected submission + * and sends an email to the data submitter. Does not check if notifications are filtered. * - * @param email - * @param emailTemplate - * @param variables + * @param file_submission_id * @returns */ async sendDataSubmitterNotification( - email: string, - variables: { - file_name: string; - user_account_name: string; - location_ids: string[]; - file_status: string; - errors: string; - warnings: string; - }, + file_submission_id: string, ): Promise { - let body = ` -

Status: {{file_status}}

-

Files Original Name: {{file_name}}

-

Date and Time of Upload: {{sys_time}}

-

Locations ID(s): ${variables.location_ids.join(", ")}

- `; - if (variables.warnings !== "") { - body += `

Warnings: {{warnings}}

`; - } - if (variables.errors !== "") { - body += `

Errors: {{errors}}

`; + const file_submission = + await this.fileSubmissionsService.findBySubmissionId(file_submission_id); + const errorLogs = + await this.fileErrorLogsService.findOne(file_submission_id); + const fileName = `${file_submission.original_file_name}-error_log.txt`; + + const email = file_submission.data_submitter_email; + if (!this.isValidEmail(email)) { + return "Invalid email"; } + const { submitter_user_id, submission_status_code } = file_submission; - const emailTemplate = { + const emailTemplate: EmailTemplate = { from: "enmodshelp@gov.bc.ca", - subject: "EnMoDS Data {{status_string}} from {{user_account_name}}", - body: body, + subject: + "EnMoDS Data {{submission_status_code}} from {{submitter_user_id}}", + body: "

Error Notification

", }; const date = new Date(); const options: Intl.DateTimeFormatOptions = { @@ -193,16 +188,13 @@ export class NotificationsService { second: "numeric", }; const sys_time = date.toLocaleString("en-US", options); - let status_string = "Imported"; - if (variables.errors !== "") { - status_string = "Failed"; - } else if (variables.warnings !== "") { - status_string = "Imported with Warnings"; - } + return this.sendEmail([email], emailTemplate, { - ...variables, + submitter_user_id: submitter_user_id, + submission_status_code: submission_status_code, + file_error_log: errorLogs, + file_name: fileName, sys_time, - status_string, }); } @@ -214,44 +206,34 @@ export class NotificationsService { * @param variables * @returns */ - async sendContactNotification( - email: string, - variables: { - file_name: string; - user_account_name: string; - file_status: string; - warnings: string; - errors: string; - }, - ): Promise { + async sendContactNotification(file_submission_id: string): Promise { + const file_submission = + await this.fileSubmissionsService.findBySubmissionId(file_submission_id); + const email = file_submission.data_submitter_email; + if (!this.isValidEmail(email)) { + return "Invalid email"; + } + const { submitter_user_id, submission_status_code } = file_submission; + + const errorLogs = + await this.fileErrorLogsService.findOne(file_submission_id); + const fileName = `${file_submission.original_file_name}-error_log.txt`; + const notificationInfo = await this.getNotificationStatus( email, - variables.user_account_name, + submitter_user_id, ); - // check that notifications are enabled before continuing - if (notificationInfo.enabled === false) { - return; - } const unsubscribeLink = process.env.WEBAPP_URL + `/unsubscribe/${notificationInfo.id}`; - let body = ` -

Status: {{file_status}}

-

Files Original Name: {{file_name}}

-

Date and Time of Upload: {{sys_time}}

-

Locations ID(s): E123445, E464353, E232524

- `; - if (variables.warnings !== "") { - body += `

Warnings: {{warnings}}

`; - } - if (variables.errors !== "") { - body += `

Errors: {{errors}}

`; - } - body += `

Unsubscribe

`; + let body = errorLogs.concat( + `

Error Notification

Unsubscribe

`, + ); const emailTemplate = { from: "enmodshelp@gov.bc.ca", - subject: "EnMoDS Data {{status_string}} from {{user_account_name}}", + subject: + "EnMoDS Data {{submission_status_code}} from {{submitter_user_id}}", body: body, }; const date = new Date(); @@ -265,16 +247,13 @@ export class NotificationsService { second: "numeric", }; const sys_time = date.toLocaleString("en-US", options); - let status_string = "Imported"; - if (variables.errors !== "") { - status_string = "Failed"; - } else if (variables.warnings !== "") { - status_string = "Imported with Warnings"; - } + return this.sendEmail([email], emailTemplate, { - ...variables, + submitter_user_id: submitter_user_id, + submission_status_code: submission_status_code, + file_error_log: errorLogs, + file_name: fileName, sys_time, - status_string, }); } @@ -292,19 +271,27 @@ export class NotificationsService { emails: string[], emailTemplate: EmailTemplate, variables: { + submitter_user_id: string; + submission_status_code: string; + file_error_log: string; file_name: string; - user_account_name: string; - file_status: string; - errors: string; - warnings: string; sys_time: string; - status_string: string; }, ): Promise { const chesToken = await this.getChesToken(); + console.log("sending email"); + // file_error_log is a string, convert it to base64 + const base64ErrorLog = btoa(variables.file_error_log); const data = JSON.stringify({ - attachments: [], + attachments: [ + { + content: base64ErrorLog, + contentType: "string", + encoding: "base64", + filename: variables.file_name, + }, + ], bodyType: "html", body: emailTemplate.body, contexts: [ @@ -325,7 +312,7 @@ export class NotificationsService { const config = { method: "post", - url: `${process.env.ches_email_url}`, + url: `${process.env.CHES_EMAIL_URL}`, headers: { "Content-Type": "application/json", Authorization: `Bearer ${chesToken}`, @@ -368,30 +355,12 @@ export class NotificationsService { * @param errors * @param ministryContact */ - async notifyUserOfError( - email: string, - username: string, - fileName: string, - errors: string[], - ministryContact: string, - ) { - const notificationVars = { - file_name: fileName, - user_account_name: username, - location_ids: [], - file_status: "FAILED", - errors: errors.join(","), - warnings: "", - }; - + async notifyUserOfError(file_submission_id: string) { // Notify the Data Submitter - if (this.isValidEmail(email)) { - await this.sendDataSubmitterNotification(email, notificationVars); - } + await this.sendDataSubmitterNotification(file_submission_id); + // Notify the Ministry Contact (if they have not disabled notifications) - if (this.isValidEmail(ministryContact)) { - await this.sendContactNotification(ministryContact, notificationVars); - } + await this.sendContactNotification(file_submission_id); } /** @@ -407,16 +376,16 @@ export class NotificationsService { errors: string[], ministryContact: string, ) { - const ftpUser = await this.prisma.ftp_users.findUnique({ - where: { username: username }, - }); - await this.notifyUserOfError( - ftpUser.email, - username, - fileName, - errors, - ministryContact, - ); + // const ftpUser = await this.prisma.ftp_users.findUnique({ + // where: { username: username }, + // }); + // await this.notifyUserOfError( + // ftpUser.email, + // username, + // fileName, + // errors, + // ministryContact, + // ); } isValidEmail(email: string): boolean { @@ -459,9 +428,9 @@ export class NotificationsService { * @returns */ async getChesToken(): Promise { - const url = process.env.ches_token_url; + const url = process.env.CHES_TOKEN_URL; const encodedToken = Buffer.from( - `${process.env.ches_client_id}:${process.env.ches_client_secret}`, + `${process.env.CHES_CLIENT_ID}:${process.env.CHES_CLIENT_SECRET}`, ).toString("base64"); const headers = { diff --git a/backend/src/objectStore/objectStore.service.ts b/backend/src/objectStore/objectStore.service.ts index 94a636a0..f516f649 100644 --- a/backend/src/objectStore/objectStore.service.ts +++ b/backend/src/objectStore/objectStore.service.ts @@ -7,45 +7,45 @@ import * as fs from "fs"; export class ObjectStoreService { private readonly logger = new Logger(ObjectStoreService.name); - private readonly objectsotreEndpoint = process.env.OBJECTSTORE_URL; + private readonly objectStoreEndpoint = process.env.OBJECTSTORE_URL; private readonly accessKey = process.env.OBJECTSTORE_ACCESS_KEY; private readonly secretKey = process.env.OBJECTSTORE_SECRET_KEY; private readonly bucketName = process.env.OBJECTSTORE_BUCKET; private readonly backupDirectory = process.env.OBJECTSTORE_BUCKET_NAME; async getFileData(fileName: string) { - try{ - if (!this.objectsotreEndpoint) { + try { + if (!this.objectStoreEndpoint) { throw new Error("Object store endpoint not defined."); } - + const dateValue = new Date().toUTCString(); - + const stringToSign = `GET\n\n\n${dateValue}\n/${this.bucketName}/${fileName}`; - + const signature = crypto .createHmac("sha1", this.secretKey) .update(stringToSign) .digest("base64"); - - const requestUrl = `${this.objectsotreEndpoint}/${this.bucketName}/${fileName}` - + + const requestUrl = `${this.objectStoreEndpoint}/${this.bucketName}/${fileName}`; + const headers = { - 'Authorization': `AWS ${this.accessKey}:${signature}`, - 'Date': dateValue, + Authorization: `AWS ${this.accessKey}:${signature}`, + Date: dateValue, }; - + const response = await axios({ - method: 'get', + method: "get", url: requestUrl, headers: headers, - responseType: 'arraybuffer', // This is important for binary data - }) - + responseType: "arraybuffer", // This is important for binary data + }); + return response.data; } catch (error) { - console.error('Error fetching the file:', error.message); - throw error; + console.error("Error fetching the file:", error.message); + // throw error; } } } diff --git a/frontend/src/pages/FileUpload.tsx b/frontend/src/pages/FileUpload.tsx index b748cce7..f48bdf85 100644 --- a/frontend/src/pages/FileUpload.tsx +++ b/frontend/src/pages/FileUpload.tsx @@ -75,6 +75,7 @@ function FileUpload() { formData.append("operation", "VALIDATE"); formData.append("userID", JWT.idir_username); // TODO: This will need to be updated based on BCeID formData.append("orgGUID", JWT.idir_user_guid); // TODO: This will need to be updated based on BCeID and company GUID + formData.append('dataSubmitterEmail', JWT.email); formData.append("token", UserService.getToken()?.toString()); await insertFile(formData).then((response) => { @@ -96,6 +97,7 @@ function FileUpload() { formData.append("operation", "VALIDATE"); formData.append("userID", JWT.idir_username); // TODO: This will need to be updated based on BCeID formData.append("orgGUID", JWT.idir_user_guid); // TODO: This will need to be updated based on BCeID and company GUID + formData.append('dataSubmitterEmail', JWT.email); formData.append("token", UserService.getToken()?.toString()); await insertFile(formData).then(async (response) => { @@ -117,6 +119,7 @@ function FileUpload() { formData.append("operation", "IMPORT"); formData.append("userID", JWT.idir_username); // TODO: This will need to be updated based on BCeID formData.append("orgGUID", JWT.idir_user_guid); // TODO: This will need to be updated based on BCeID and company GUID + formData.append('dataSubmitterEmail', JWT.email); formData.append("token", UserService.getToken()?.toString()); await insertFile(formData).then((response) => { @@ -138,6 +141,7 @@ function FileUpload() { formData.append("operation", "IMPORT"); formData.append("userID", JWT.idir_username); // TODO: This will need to be updated based on BCeID formData.append("orgGUID", JWT.idir_user_guid); // TODO: This will need to be updated based on BCeID and company GUID + formData.append('dataSubmitterEmail', JWT.email); formData.append("token", UserService.getToken()?.toString()); await insertFile(formData).then(async (response) => { diff --git a/migrations/sql/v1.0.8__aqi_data_classifications.sql b/migrations/sql/V1.0.8__aqi_data_classifications.sql similarity index 100% rename from migrations/sql/v1.0.8__aqi_data_classifications.sql rename to migrations/sql/V1.0.8__aqi_data_classifications.sql diff --git a/migrations/sql/V1.1.1__notifications_updates.sql b/migrations/sql/V1.1.1__notifications_updates.sql new file mode 100644 index 00000000..742f9c27 --- /dev/null +++ b/migrations/sql/V1.1.1__notifications_updates.sql @@ -0,0 +1,2 @@ +ALTER TABLE enmods.file_submission +ADD COLUMN data_submitter_email varchar(200);