diff --git a/package-lock.json b/package-lock.json index 335234c..d7eca51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "LGPL-3.0-only", "dependencies": { + "@types/csv-parse": "^1.2.5", "@types/uuid": "^9.0.6", "bcrypt": "^5.1.0", "cookie-parser": "^1.4.6", + "csv-parse": "^5.5.6", "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.0.1", @@ -21,7 +23,8 @@ "nodemailer": "^6.9.7", "path": "^0.12.7", "sqlite3": "^5.1.6", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "yauzl": "^3.1.3" }, "devDependencies": { "@types/bcrypt": "^5.0.0", @@ -35,6 +38,7 @@ "@types/shelljs": "^0.8.15", "@types/sqlite3": "^3.1.8", "@types/supertest": "^2.0.12", + "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/parser": "^6.2.0", "eslint": "^8.45.0", @@ -1533,6 +1537,15 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "node_modules/@types/csv-parse": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/csv-parse/-/csv-parse-1.2.5.tgz", + "integrity": "sha512-3PoFyWeuFGqale09vFydLQ6IGdvD+mizcXcB8s6ImWv+830IF0HckvewgcGVfGnTFImqvfvhpYZYod2QqGGGdg==", + "deprecated": "This is a stub types definition. csv-parse provides its own type definitions, so you do not need this installed.", + "dependencies": { + "csv-parse": "*" + } + }, "node_modules/@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", @@ -1776,6 +1789,15 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.2.0.tgz", @@ -2687,6 +2709,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3076,6 +3106,11 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.6.tgz", + "integrity": "sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==" + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -7066,6 +7101,11 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -8903,6 +8943,18 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.1.3.tgz", + "integrity": "sha512-JCCdmlJJWv7L0q/KylOekyRaUrdEoUxWkWVcgorosTROCFWiS9p2NNPE9Yb91ak7b1N5SxAZEliWpspbZccivw==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index cf6d479..7bb0143 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,11 @@ "author": "Julie Coustenoble", "license": "LGPL-3.0-only", "dependencies": { + "@types/csv-parse": "^1.2.5", "@types/uuid": "^9.0.6", "bcrypt": "^5.1.0", "cookie-parser": "^1.4.6", + "csv-parse": "^5.5.6", "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.0.1", @@ -32,7 +34,8 @@ "nodemailer": "^6.9.7", "path": "^0.12.7", "sqlite3": "^5.1.6", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "yauzl": "^3.1.3" }, "devDependencies": { "@types/bcrypt": "^5.0.0", @@ -46,6 +49,7 @@ "@types/shelljs": "^0.8.15", "@types/sqlite3": "^3.1.8", "@types/supertest": "^2.0.12", + "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/parser": "^6.2.0", "eslint": "^8.45.0", @@ -60,4 +64,4 @@ "ts-node": "^10.9.2", "typescript": "^5.2.2" } -} \ No newline at end of file +} diff --git a/src/data/data-sources/sqlite/sqlite-project-data-source.ts b/src/data/data-sources/sqlite/sqlite-project-data-source.ts index 326cfe3..2efcdb8 100644 --- a/src/data/data-sources/sqlite/sqlite-project-data-source.ts +++ b/src/data/data-sources/sqlite/sqlite-project-data-source.ts @@ -116,7 +116,7 @@ export class SQLiteProjectDataSource implements ProjectDataSource { const params: any[] = [] let placeholders: string = "" for (const [key, value] of Object.entries(projectData)) { - if (key == "enable_descent_filter") { // TODO somewhere else? serializer? + if (key == "enable_descent_filter") { params.push(value == true || value == "true" ? 1 : 0) // TODO clean } else { params.push(value) diff --git a/src/data/data-sources/sqlite/sqlite-sample-data-source.ts b/src/data/data-sources/sqlite/sqlite-sample-data-source.ts new file mode 100644 index 0000000..67e7753 --- /dev/null +++ b/src/data/data-sources/sqlite/sqlite-sample-data-source.ts @@ -0,0 +1,633 @@ +import { SQLiteDatabaseWrapper } from "../../interfaces/data-sources/database-wrapper"; +//SampleRequestModel, SampleUpdateModel, SampleResponseModel +import { MinimalSampleRequestModel, PrivateSampleUpdateModel, PublicSampleModel, SampleIdModel, SampleRequestCreationModel, SampleTypeModel, SampleTypeRequestModel, VisualQualityCheckStatusModel, VisualQualityCheckStatusRequestModel, } from "../../../domain/entities/sample"; +import { SampleDataSource } from "../../interfaces/data-sources/sample-data-source"; +import { PreparedSearchOptions, SearchResult } from "../../../domain/entities/search"; +//import { PreparedSearchOptions, SearchResult } from "../../../domain/entities/search"; + +// const DB_TABLE = "sample" +export class SQLiteSampleDataSource implements SampleDataSource { + + private db: SQLiteDatabaseWrapper + constructor(db: SQLiteDatabaseWrapper) { + this.db = db + this.init_sample_db() + } + + init_sample_db(): void { + this.createSampleTypeTable() + this.createVisualQualityCheckTable() + this.createSampleTable() + } + + createSampleTypeTable(): void { + // SQL statement to create the sample_type table if it does not exist + const sql_sample_type = ` + CREATE TABLE IF NOT EXISTS sample_type ( + sample_type_id INTEGER PRIMARY KEY AUTOINCREMENT, + sample_type_label TEXT NOT NULL, + sample_type_description TEXT NOT NULL + ); + `; + + // Run the SQL query to create the table + const db_tables = this.db; + db_tables.run(sql_sample_type, [], function (err: Error | null) { + if (err) { + console.log("DB error--", err); + return; // Return early if there's an error creating the table + } else { + // Insert default task_type + const sql_admin = ` + INSERT OR IGNORE INTO sample_type (sample_type_label, sample_type_description) + VALUES + ('Time', 'Time série'), + ('Depth', 'Depth profile'); + `; + + db_tables.run(sql_admin, [], function (err: Error | null) { + if (err) { + console.log("DB error--", err); + } + }); + } + }); + } + + createVisualQualityCheckTable(): void { + // SQL statement to create the visual_quality_check_status table if it does not exist + const sql_sample_visual_quality_check_status = ` + CREATE TABLE IF NOT EXISTS visual_quality_check_status ( + visual_qc_status_id INTEGER PRIMARY KEY AUTOINCREMENT, + visual_qc_status_label TEXT NOT NULL + ); + `; + + // Run the SQL query to create the table + const db_tables = this.db; + db_tables.run(sql_sample_visual_quality_check_status, [], function (err: Error | null) { + if (err) { + console.log("DB error--", err); + return; // Return early if there's an error creating the table + } else { + // Insert default visual_quality_check_status entries + const sql_admin = ` + INSERT OR IGNORE INTO visual_quality_check_status (visual_qc_status_label) + VALUES + ('PENDING'), + ('VALIDATED'), + ('REJECTED'); // Removed the trailing comma and added a semicolon + `; + + db_tables.run(sql_admin, [], function (err: Error | null) { + if (err) { + console.log("DB error--", err); + } + }); + } + }); + } + + createSampleTable(): void { + // SQL statement to create the sample table if it does not exist + const sql_create_sample = ` + CREATE TABLE IF NOT EXISTS 'sample' ( + sample_id INTEGER PRIMARY KEY AUTOINCREMENT, + sample_name TEXT NOT NULL, + comment TEXT NOT NULL, + instrument_serial_number TEXT NOT NULL, + optional_structure_id TEXT, + max_pressure INTEGER NOT NULL, + integration_time INTEGER, + station_id TEXT NOT NULL, + sampling_date TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + wind_direction INTEGER, + wind_speed INTEGER, + sea_state TEXT, + nebulousness INTEGER, + bottom_depth INTEGER, + instrument_operator_email TEXT NOT NULL, + filename TEXT NOT NULL, + sample_creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + filter_first_image TEXT NOT NULL, + filter_last_image TEXT NOT NULL, + + instrument_settings_acq_gain INTEGER NOT NULL, + instrument_settings_acq_description TEXT, + instrument_settings_acq_task_type TEXT, + instrument_settings_acq_choice TEXT, + instrument_settings_acq_disk_type TEXT, + instrument_settings_acq_appendices_ratio INTEGER NOT NULL, + instrument_settings_acq_xsize INTEGER, + instrument_settings_acq_ysize INTEGER, + instrument_settings_acq_erase_border INTEGER, + instrument_settings_acq_threshold INTEGER NOT NULL, + instrument_settings_process_datetime TEXT, + instrument_settings_process_gamma INTEGER NOT NULL, + instrument_settings_images_post_process TEXT NOT NULL, + instrument_settings_aa INTEGER NOT NULL, + instrument_settings_exp INTEGER NOT NULL, + instrument_settings_image_volume_l INTEGER NOT NULL, + instrument_settings_pixel_size_mm INTEGER NOT NULL, + instrument_settings_depth_offset_m INTEGER NOT NULL, + instrument_settings_particle_minimum_size_pixels INTEGER, + instrument_settings_vignettes_minimum_size_pixels INTEGER, + instrument_settings_particle_minimum_size_esd INTEGER, + instrument_settings_vignettes_minimum_size_esd INTEGER, + instrument_settings_acq_shutter_speed INTEGER, + instrument_settings_acq_shutter_speed INTEGER, + instrument_settings_acq_exposure INTEGER, + + visual_qc_validator_user_id INTEGER NOT NULL, + visual_qc_status_id INTEGER NOT NULL DEFAULT 1, + sample_type_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + + FOREIGN KEY (visual_qc_validator_user_id) REFERENCES user(user_id), + FOREIGN KEY (visual_qc_status_id) REFERENCES visual_quality_check_status(visual_qc_status_id) + FOREIGN KEY (sample_type_id) REFERENCES sample_type(sample_type_id), + FOREIGN KEY (project_id) REFERENCES project(project_id) + + CONSTRAINT Unique_proj_id_sample_name UNIQUE (project_id, sample_name) + ); + `; + // Run the SQL query to create the table + this.db.run(sql_create_sample, [], (err: Error | null) => { + if (err) { + console.error("Error creating the 'sample' table:", err.message); + return; // Return early if there's an error creating the table + } + }); + } + + async createOne(sample: SampleRequestCreationModel): Promise { + const params = [sample.sample_name, sample.comment, sample.instrument_serial_number, sample.optional_structure_id, sample.max_pressure, sample.integration_time, sample.station_id, sample.sampling_date, sample.latitude, sample.longitude, sample.wind_direction, sample.wind_speed, sample.sea_state, sample.nebulousness, sample.bottom_depth, sample.instrument_operator_email, sample.filename, sample.filter_first_image, sample.filter_last_image, sample.instrument_settings_acq_gain, sample.instrument_settings_acq_description, sample.instrument_settings_acq_task_type, sample.instrument_settings_acq_choice, sample.instrument_settings_acq_disk_type, sample.instrument_settings_acq_appendices_ratio, sample.instrument_settings_acq_xsize, sample.instrument_settings_acq_ysize, sample.instrument_settings_acq_erase_border, sample.instrument_settings_acq_threshold, sample.instrument_settings_process_datetime, sample.instrument_settings_process_gamma, sample.instrument_settings_images_post_process, sample.instrument_settings_aa, sample.instrument_settings_exp, sample.instrument_settings_image_volume_l, sample.instrument_settings_pixel_size_mm, sample.instrument_settings_depth_offset_m, sample.instrument_settings_particle_minimum_size_pixels, sample.instrument_settings_vignettes_minimum_size_pixels, sample.instrument_settings_particle_minimum_size_esd, sample.instrument_settings_vignettes_minimum_size_esd, sample.instrument_settings_acq_shutter, sample.instrument_settings_acq_shutter_speed, sample.instrument_settings_acq_exposure, sample.visual_qc_validator_user_id, sample.sample_type_id, sample.project_id] + const placeholders = params.map(() => '(?)').join(','); // TODO create tool funct + const sql = `INSERT INTO sample (sample_name, comment, instrument_serial_number, optional_structure_id, max_pressure, integration_time, station_id, sampling_date, latitude, longitude, wind_direction, wind_speed, sea_state, nebulousness, bottom_depth, instrument_operator_email, filename, filter_first_image, filter_last_image, instrument_settings_acq_gain, instrument_settings_acq_description, instrument_settings_acq_task_type, instrument_settings_acq_choice, instrument_settings_acq_disk_type, instrument_settings_acq_appendices_ratio, instrument_settings_acq_xsize, instrument_settings_acq_ysize, instrument_settings_acq_erase_border, instrument_settings_acq_threshold, instrument_settings_process_datetime, instrument_settings_process_gamma, instrument_settings_images_post_process, instrument_settings_aa, instrument_settings_exp, instrument_settings_image_volume_l, instrument_settings_pixel_size_mm, instrument_settings_depth_offset_m, instrument_settings_particle_minimum_size_pixels, instrument_settings_vignettes_minimum_size_pixels, instrument_settings_particle_minimum_size_esd, instrument_settings_vignettes_minimum_size_esd, instrument_settings_acq_shutter, instrument_settings_acq_shutter_speed, instrument_settings_acq_exposure, visual_qc_validator_user_id, sample_type_id, project_id + ) VALUES (` + placeholders + `);`; + + return await new Promise((resolve, reject) => { + this.db.run(sql, params, function (err) { + if (err) { + console.log("DB error--", err) + reject(err); + } else { + const result = this.lastID; + resolve(result); + } + }); + }) + } + + async createMany(samples: SampleRequestCreationModel[]): Promise { + return new Promise((resolve, reject) => { + const insertedIds: number[] = []; + + // Begin transaction + this.db.run('BEGIN TRANSACTION', (beginErr: Error) => { + if (beginErr) { + console.log("Failed to begin transaction:", beginErr); + return reject(beginErr); + } + + const insertPromises = samples.map((sample) => { + return new Promise((resolveInsert, rejectInsert) => { + const params = [ + sample.sample_name, sample.comment, sample.instrument_serial_number, + sample.optional_structure_id, sample.max_pressure, sample.integration_time, sample.station_id, + sample.sampling_date, sample.latitude, sample.longitude, sample.wind_direction, + sample.wind_speed, sample.sea_state, sample.nebulousness, sample.bottom_depth, + sample.instrument_operator_email, sample.filename, sample.filter_first_image, + sample.filter_last_image, + sample.instrument_settings_acq_gain, sample.instrument_settings_acq_description, + sample.instrument_settings_acq_task_type, sample.instrument_settings_acq_choice, + sample.instrument_settings_acq_disk_type, sample.instrument_settings_acq_appendices_ratio, + sample.instrument_settings_acq_xsize, sample.instrument_settings_acq_ysize, + sample.instrument_settings_acq_erase_border, sample.instrument_settings_acq_threshold, + sample.instrument_settings_process_datetime, + sample.instrument_settings_process_gamma, sample.instrument_settings_images_post_process, + sample.instrument_settings_aa, sample.instrument_settings_exp, + sample.instrument_settings_image_volume_l, sample.instrument_settings_pixel_size_mm, + sample.instrument_settings_depth_offset_m, sample.instrument_settings_particle_minimum_size_pixels, + sample.instrument_settings_vignettes_minimum_size_pixels, + sample.instrument_settings_particle_minimum_size_esd, + sample.instrument_settings_vignettes_minimum_size_esd, + sample.instrument_settings_acq_shutter, + sample.instrument_settings_acq_shutter_speed, sample.instrument_settings_acq_exposure, + sample.visual_qc_validator_user_id, sample.sample_type_id, sample.project_id + ]; + + const placeholders = params.map(() => '?').join(', '); + const sql = `INSERT INTO sample ( + sample_name, comment, instrument_serial_number, optional_structure_id, max_pressure, + integration_time, station_id, sampling_date, latitude, longitude, wind_direction, wind_speed, sea_state, + nebulousness, bottom_depth, instrument_operator_email, filename, filter_first_image, filter_last_image, + instrument_settings_acq_gain, instrument_settings_acq_description, instrument_settings_acq_task_type, + instrument_settings_acq_choice, instrument_settings_acq_disk_type, instrument_settings_acq_appendices_ratio, + instrument_settings_acq_xsize, instrument_settings_acq_ysize, instrument_settings_acq_erase_border, + instrument_settings_acq_threshold, instrument_settings_process_datetime, + instrument_settings_process_gamma, instrument_settings_images_post_process, + instrument_settings_aa, instrument_settings_exp, instrument_settings_image_volume_l, + instrument_settings_pixel_size_mm, instrument_settings_depth_offset_m, instrument_settings_particle_minimum_size_pixels, + instrument_settings_vignettes_minimum_size_pixels, instrument_settings_acq_shutter_speed, + instrument_settings_particle_minimum_size_esd, instrument_settings_vignettes_minimum_size_esd, + instrument_settings_acq_shutter, instrument_settings_acq_exposure, visual_qc_validator_user_id, sample_type_id, project_id + ) VALUES (${placeholders})`; + + this.db.run(sql, params, function (err) { + if (err) { + console.log("Failed to insert sample:", err); + rejectInsert(err); + } else { + insertedIds.push(this.lastID); + resolveInsert(this.lastID); + } + }); + }); + }); + + Promise.all(insertPromises) + .then(() => { + // Commit transaction if all inserts are successful + this.db.run('COMMIT', (commitErr: Error) => { + if (commitErr) { + console.log("Failed to commit transaction:", commitErr); + return reject(commitErr); + } + resolve(insertedIds); + }); + }) + .catch((error) => { + // Rollback transaction if any insert fails + this.db.run('ROLLBACK', (rollbackErr: Error) => { + if (rollbackErr) { + console.log("Failed to rollback transaction:", rollbackErr); + } else { + console.log("Transaction rolled back due to error:", error); + } + reject(error); + }); + }); + }); + }); + } + + async getOne(sample: MinimalSampleRequestModel): Promise { + //can be search by sample_id, by sample_name and project_id + const params: any[] = [] + let placeholders: string = "" + // generate sql and params + for (const [key, value] of Object.entries(sample)) { + params.push(value) + placeholders = placeholders + "sample." + key + "=(?) AND " + } + // remove last AND + placeholders = placeholders.slice(0, -4); + + // if no params, return null + if (params.length == 0) { + return null + } + + // form final sql + const sql = `SELECT sample.*, user.first_name, user.last_name, user.email, sample_type.sample_type_label, visual_quality_check_status.visual_qc_status_label FROM sample LEFT JOIN sample_type ON sample.sample_type_id = sample_type.sample_type_id LEFT JOIN visual_quality_check_status on sample.visual_qc_status_id = visual_quality_check_status.visual_qc_status_id LEFT JOIN user on sample.visual_qc_validator_user_id=user.user_id WHERE ` + placeholders + `LIMIT 1;`; + return await new Promise((resolve, reject) => { + this.db.get(sql, params, (err, row) => { + if (err) { + reject(err); + } else { + if (row === undefined) resolve(null); + else { + const result = { + sample_id: row.sample_id, + sample_name: row.sample_name, + comment: row.comment, + instrument_serial_number: row.instrument_serial_number, + optional_structure_id: row.optional_structure_id, + max_pressure: row.max_pressure, + integration_time: row.integration_time, + station_id: row.station_id, + sampling_date: row.sampling_date, + latitude: row.latitude, + longitude: row.longitude, + wind_direction: row.wind_direction, + wind_speed: row.wind_speed, + sea_state: row.sea_state, + nebulousness: row.nebulousness, + bottom_depth: row.bottom_depth, + instrument_operator_email: row.instrument_operator_email, + filename: row.filename, + sample_creation_date: row.sample_creation_date, + filter_first_image: row.filter_first_image, + filter_last_image: row.filter_last_image, + instrument_settings_acq_gain: row.instrument_settings_acq_gain, + instrument_settings_acq_description: row.instrument_settings_acq_description, + instrument_settings_acq_task_type: row.instrument_settings_acq_task_type, + instrument_settings_acq_choice: row.instrument_settings_acq_choice, + instrument_settings_acq_disk_type: row.instrument_settings_acq_disk_type, + instrument_settings_acq_appendices_ratio: row.instrument_settings_acq_appendices_ratio, + instrument_settings_acq_xsize: row.instrument_settings_acq_xsize, + instrument_settings_acq_ysize: row.instrument_settings_acq_ysize, + instrument_settings_acq_erase_border: row.instrument_settings_acq_erase_border, + instrument_settings_acq_threshold: row.instrument_settings_acq_threshold, + instrument_settings_process_datetime: row.instrument_settings_process_datetime, + instrument_settings_process_gamma: row.instrument_settings_process_gamma, + instrument_settings_images_post_process: row.instrument_settings_images_post_process, + instrument_settings_aa: row.instrument_settings_aa, + instrument_settings_exp: row.instrument_settings_exp, + instrument_settings_image_volume_l: row.instrument_settings_image_volume_l, + instrument_settings_pixel_size_mm: row.instrument_settings_pixel_size_mm, + instrument_settings_depth_offset_m: row.instrument_settings_depth_offset_m, + instrument_settings_particle_minimum_size_pixels: row.instrument_settings_particle_minimum_size_pixels, + instrument_settings_vignettes_minimum_size_pixels: row.instrument_settings_vignettes_minimum_size_pixels, + instrument_settings_particle_minimum_size_esd: row.instrument_settings_particle_minimum_size_esd, + instrument_settings_vignettes_minimum_size_esd: row.instrument_settings_vignettes_minimum_size_esd, + instrument_settings_acq_shutter: row.instrument_settings_acq_shutter_speed, + instrument_settings_acq_shutter_speed: row.instrument_settings_acq_shutter_speed, + instrument_settings_acq_exposure: row.instrument_settings_acq_exposure, + visual_qc_validator_user_id: row.visual_qc_validator_user_id, + visual_qc_validator_user: row.user_first_name + " " + row.user_last_name + " (" + row.email + ")", // Doe John (john.doe@mail.com) + visual_qc_status_id: row.visual_qc_status_id, + visual_qc_status_label: row.visual_qc_status_label, + sample_type_id: row.sample_type_id, + sample_type_label: row.sample_type_label, + project_id: row.project_id + }; + resolve(result); + } + } + }); + }) + } + + async deleteOne(sample: SampleIdModel): Promise { + // delete sample based on sample_id + const sql = `DELETE FROM sample WHERE sample_id = (?)`; + return await new Promise((resolve, reject) => { + this.db.run(sql, [sample.sample_id], function (err) { + if (err) { + console.log("DB error--", err) + reject(err); + } else { + const result = this.changes; //RETURN NB OF CHANGES + resolve(result); + } + }); + }) + } + + + async getAll(options: PreparedSearchOptions): Promise> { + // Get the limited rows and the total count of rows // WHERE your_condition + let sql = `SELECT sample.*, user.first_name, user.last_name, user.email, sample_type.sample_type_label, visual_quality_check_status.visual_qc_status_label, (SELECT COUNT(*) FROM sample` + const params: any[] = [] + let filtering_sql = "" + const params_filtering: any[] = [] + // Add filtering + if (options.filter.length > 0) { + filtering_sql += ` WHERE `; + // For each filter, add to filtering_sql and params_filtering + for (const filter of options.filter) { + if (filter.operator == "IN" && Array.isArray(filter.value) && filter.value.length > 0) { + // if array do not contains null or undefined + if (!filter.value.includes(null) && !filter.value.includes(undefined) && filter.value.length > 0) { + // for eatch value in filter.value, add to filtering_sql and params_filtering + filtering_sql += `sample.` + filter.field + ` IN (` + filter.value.map(() => '(?)').join(',') + `) ` + params_filtering.push(...filter.value) + } + } + // If value is true or false, set to 1 or 0 + else if (filter.value == true || filter.value == "true") { + filtering_sql += `sample.` + filter.field + ` = 1`; + } + else if (filter.value == false || filter.value == "false") { + filtering_sql += `sample.` + filter.field + ` = 0`; + } + // If value is undefined, null or empty, and operator =, set to is null + else if (filter.value == "null") { + if (filter.operator == "=") { + filtering_sql += `sample.` + filter.field + ` IS NULL`; + } else if (filter.operator == "!=") { + filtering_sql += `sample.` + filter.field + ` IS NOT NULL`; + } + } + + else { + filtering_sql += `sample.` + filter.field + ` ` + filter.operator + ` (?)` + params_filtering.push(filter.value) + } + filtering_sql += ` AND `; + } + // remove last AND + filtering_sql = filtering_sql.slice(0, -4); + } + // Add filtering_sql to sql + sql += filtering_sql + // Add params_filtering to params + params.push(...params_filtering) + + sql += `) AS total_count FROM sample LEFT JOIN sample_type ON sample.sample_type_id = sample_type.sample_type_id LEFT JOIN visual_quality_check_status on sample.visual_qc_status_id = visual_quality_check_status.visual_qc_status_id LEFT JOIN user on sample.visual_qc_validator_user_id=user.user_id` + + + // Add filtering_sql to sql + sql += filtering_sql + // Add params_filtering to params + params.push(...params_filtering) + + // Add sorting + if (options.sort_by.length > 0) { + sql += ` ORDER BY`; + for (const sort of options.sort_by) { + sql += ` ` + sort.sort_by + ` ` + sort.order_by + `,`; + } + // remove last , + sql = sql.slice(0, -1); + } + + // Add pagination + const page = options.page; + const limit = options.limit; + const offset = (page - 1) * limit; + sql += ` LIMIT (?) OFFSET (?)`; + params.push(limit, offset); + + // Add final ; + sql += `;` + + return await new Promise((resolve, reject) => { + this.db.all(sql, params, (err, rows) => { + if (err) { + reject(err); + } else { + if (rows === undefined) resolve({ items: [], total: 0 }); + const result: SearchResult = { + items: rows.map(row => ({ + sample_id: row.sample_id, + sample_name: row.sample_name, + comment: row.comment, + instrument_serial_number: row.instrument_serial_number, + optional_structure_id: row.optional_structure_id, + max_pressure: row.max_pressure, + integration_time: row.integration_time, + station_id: row.station_id, + sampling_date: row.sampling_date, + latitude: row.latitude, + longitude: row.longitude, + wind_direction: row.wind_direction, + wind_speed: row.wind_speed, + sea_state: row.sea_state, + nebulousness: row.nebulousness, + bottom_depth: row.bottom_depth, + instrument_operator_email: row.instrument_operator_email, + filename: row.filename, + sample_creation_date: row.sample_creation_date, + filter_first_image: row.filter_first_image, + filter_last_image: row.filter_last_image, + instrument_settings_acq_gain: row.instrument_settings_acq_gain, + instrument_settings_acq_description: row.instrument_settings_acq_description, + instrument_settings_acq_task_type: row.instrument_settings_acq_task_type, + instrument_settings_acq_choice: row.instrument_settings_acq_choice, + instrument_settings_acq_disk_type: row.instrument_settings_acq_disk_type, + instrument_settings_acq_appendices_ratio: row.instrument_settings_acq_appendices_ratio, + instrument_settings_acq_xsize: row.instrument_settings_acq_xsize, + instrument_settings_acq_ysize: row.instrument_settings_acq_ysize, + instrument_settings_acq_erase_border: row.instrument_settings_acq_erase_border, + instrument_settings_acq_threshold: row.instrument_settings_acq_threshold, + instrument_settings_process_datetime: row.instrument_settings_process_datetime, + instrument_settings_process_gamma: row.instrument_settings_process_gamma, + instrument_settings_images_post_process: row.instrument_settings_images_post_process, + instrument_settings_aa: row.instrument_settings_aa, + instrument_settings_exp: row.instrument_settings_exp, + instrument_settings_image_volume_l: row.instrument_settings_image_volume_l, + instrument_settings_pixel_size_mm: row.instrument_settings_pixel_size_mm, + instrument_settings_depth_offset_m: row.instrument_settings_depth_offset_m, + instrument_settings_particle_minimum_size_pixels: row.instrument_settings_particle_minimum_size_pixels, + instrument_settings_vignettes_minimum_size_pixels: row.instrument_settings_vignettes_minimum_size_pixels, + instrument_settings_particle_minimum_size_esd: row.instrument_settings_particle_minimum_size_esd, + instrument_settings_vignettes_minimum_size_esd: row.instrument_settings_vignettes_minimum_size_esd, + instrument_settings_acq_shutter: row.instrument_settings_acq_shutter_speed, + instrument_settings_acq_shutter_speed: row.instrument_settings_acq_shutter_speed, + instrument_settings_acq_exposure: row.instrument_settings_acq_exposure, + visual_qc_validator_user_id: row.visual_qc_validator_user_id, + visual_qc_validator_user: row.user_first_name + " " + row.user_last_name + " (" + row.email + ")", // Doe John (john.doe@mail.com) + visual_qc_status_id: row.visual_qc_status_id, + visual_qc_status_label: row.visual_qc_status_label, + sample_type_id: row.sample_type_id, + sample_type_label: row.sample_type_label, + project_id: row.project_id + })), + total: rows[0]?.total_count || 0 + }; + resolve(result); + } + }); + }) + } + + // Update One Sample + // Returns the number of lines updates + async updateOne(sample: PrivateSampleUpdateModel): Promise { + console.log("######TODO: updateOne sample#######") + const { sample_id, ...sampleData } = sample; // Destructure the sample object + const params: any[] = [] + let placeholders: string = "" + // Generate sql and params + for (const [key, value] of Object.entries(sampleData)) { + params.push(value) + placeholders = placeholders + key + "=(?)," + } + // Remove last , + placeholders = placeholders.slice(0, -1); + // add sample_id to params + params.push(sample_id) + + // Form final sql + const sql = `UPDATE sample SET ` + placeholders + ` WHERE sample_id=(?);`; + return await new Promise((resolve, reject) => { + this.db.run(sql, params, function (err) { + if (err) { + reject(err); + } else { + const result = this.changes; + resolve(result); + } + }); + }) + } + + async getSampleType(sampleType: SampleTypeRequestModel): Promise { + //can be search by sample_type_id, sample_type_label, sample_type_description + const params: any[] = [] + let placeholders: string = "" + // generate sql and params + for (const [key, value] of Object.entries(sampleType)) { + params.push(value) + placeholders = placeholders + key + "=(?) AND " + } + // remove last AND + placeholders = placeholders.slice(0, -4); + + // if no params, return null + if (params.length == 0) { + return null + } + + const sql = `SELECT * FROM sample_type WHERE ` + placeholders + `LIMIT 1;`; + return await new Promise((resolve, reject) => { + this.db.get(sql, params, (err, row) => { + if (err) { + reject(err); + } else { + if (row === undefined) resolve(null); + else { + const result = { + sample_type_id: row.sample_type_id, + sample_type_label: row.sample_type_label, + sample_type_description: row.sample_type_description + }; + resolve(result); + } + } + }); + }) + } + + async getVisualQCStatus(visualQCStatus: VisualQualityCheckStatusRequestModel): Promise { + //can be search by visual_qc_status_id, visual_qc_status_label + const params: any[] = [] + let placeholders: string = "" + // generate sql and params + for (const [key, value] of Object.entries(visualQCStatus)) { + params.push(value) + placeholders = placeholders + key + "=(?) AND " + } + // remove last AND + placeholders = placeholders.slice(0, -4); + + // if no params, return null + if (params.length == 0) { + return null + } + const sql = `SELECT * FROM visual_quality_check_status WHERE ` + placeholders + `LIMIT 1;`; + return await new Promise((resolve, reject) => { + this.db.get(sql, params, (err, row) => { + if (err) { + reject(err); + } else { + if (row === undefined) resolve(null); + else { + const result = { + visual_qc_status_id: row.visual_qc_status_id, + visual_qc_status_label: row.visual_qc_status_label + }; + resolve(result); + } + } + }); + }) + } +} + diff --git a/src/data/interfaces/data-sources/sample-data-source.ts b/src/data/interfaces/data-sources/sample-data-source.ts new file mode 100644 index 0000000..dd7b9de --- /dev/null +++ b/src/data/interfaces/data-sources/sample-data-source.ts @@ -0,0 +1,14 @@ +import { MinimalSampleRequestModel, PrivateSampleUpdateModel, PublicSampleModel, SampleIdModel, SampleRequestCreationModel, SampleTypeModel, SampleTypeRequestModel, VisualQualityCheckStatusModel, VisualQualityCheckStatusRequestModel, } from "../../../domain/entities/sample"; +import { PreparedSearchOptions, SearchResult } from "../../../domain/entities/search"; +//import { PreparedSearchOptions, SearchResult } from "../../../domain/entities/search"; +//SampleTypeModel, SampleTypeRequestModel, SampleRequestModel, SampleUpdateModel, SampleResponseModel, QualityCheckStatusRequestModel, QualityCheckStatusModel +export interface SampleDataSource { + createOne(sample: SampleRequestCreationModel): Promise; + createMany(samples: SampleRequestCreationModel[]): Promise; + getOne(sample: MinimalSampleRequestModel): Promise; + deleteOne(sample: SampleIdModel): Promise; + getAll(options: PreparedSearchOptions): Promise>; + updateOne(sample: PrivateSampleUpdateModel): Promise; + getSampleType(sampleType: SampleTypeRequestModel): Promise; + getVisualQCStatus(visualQCStatus: VisualQualityCheckStatusRequestModel): Promise; +} \ No newline at end of file diff --git a/src/domain/entities/sample.ts b/src/domain/entities/sample.ts index a92cdec..fd1aff8 100644 --- a/src/domain/entities/sample.ts +++ b/src/domain/entities/sample.ts @@ -1,51 +1,269 @@ +/* SAMPLE */ -export interface PublicSampleResponseModel { - sample_name: string, - raw_file_name: string, - station_id: string, - first_image: string, - last_image: string, - comment: string, - qc_lvl1: boolean, - qc_lvl1_comment: string, +export interface SampleRequestCreationModel { + sample_name: string; // Sample name + comment: string; // Optional comment + instrument_serial_number: string; // Instrument serial number + optional_structure_id?: string; // Optional id (Argo / Glider) + max_pressure: number; // Maximum pressure (in relevant unit) + integration_time?: number; // Integration time (in seconds or relevant unit) + station_id: string; // Station identifier + sampling_date: string; // Sampling date in ISO format + latitude: number; // Latitude (in decimal degrees) [DD.DDDD] (- for South) + longitude: number; // Longitude (in decimal degrees) [DDD.DDDD] (- for West) + wind_direction: number; // Wind direction (in degrees) + wind_speed: number; // Wind speed (in relevant unit) + sea_state: string; // Description or classification of sea state + nebulousness: number; // Cloud coverage percentage (0-100) + bottom_depth: number; // Bottom depth (in meters or relevant unit) + instrument_operator_email: string; // Operator's email + filename: string; // source file name + + filter_first_image: string; // First image + filter_last_image: string; // Last image + + instrument_settings_acq_gain: number; // Acquisition gain + instrument_settings_acq_description: string | undefined; // Acquisition description + instrument_settings_acq_task_type: string | undefined; // Acquisition task type + instrument_settings_acq_choice: string | undefined; // Acquisition choice + instrument_settings_acq_disk_type: string | undefined; // Acquisition disk type + instrument_settings_acq_appendices_ratio: number; // Acquisition ratio + instrument_settings_acq_xsize: number | undefined; // Acquisition X size (in pixels or relevant unit) + instrument_settings_acq_ysize: number | undefined; // Acquisition Y size (in pixels or relevant unit) + instrument_settings_acq_erase_border: number | undefined; // Acquisition erase border (0/1 boolean) + instrument_settings_acq_threshold: number; // Acquisition threshold value + instrument_settings_process_datetime: string | undefined; // Process date and time (ISO format) + instrument_settings_process_gamma: number | undefined // Process gamma value + instrument_settings_images_post_process: string; // Image post-processing details + instrument_settings_aa: number; // Aa value for UVP6 (divided by 10^6) + instrument_settings_exp: number; // Exp value + instrument_settings_image_volume_l: number; // Image volume in liters + instrument_settings_pixel_size_mm: number; // Pixel size in millimeters + instrument_settings_depth_offset_m: number; // Depth offset in meters + instrument_settings_particle_minimum_size_pixels: number | undefined; // Particle minimum size in pixels + instrument_settings_vignettes_minimum_size_pixels: number | undefined; // Vignettes minimum size in pixels + instrument_settings_particle_minimum_size_esd: number | undefined; // Particle minimum size in esd + instrument_settings_vignettes_minimum_size_esd: number | undefined; // Vignettes minimum size in esd + instrument_settings_acq_shutter: number | undefined; // Acquisition shutter + instrument_settings_acq_shutter_speed: number | undefined; // Acquisition shutter speed (in seconds or relevant unit) + instrument_settings_acq_exposure: number | undefined; // Acquisition exposure (in seconds or relevant unit) + + visual_qc_validator_user_id: number; // Quality check validator user identifier + sample_type_id: number; // Sample type depth or time + project_id: number; // Project identifier +} + +export interface PrivateSampleModel extends SampleRequestCreationModel { + sample_id: number; // Sample internal identifier + sample_creation_date: string; // Creation date in ISO format + visual_qc_status_id: number; // Quality check status +} +export interface PublicSampleModel extends PrivateSampleModel { + sample_type_label: string; // Sample type name + visual_qc_status_label: string; // Quality check status + visual_qc_validator_user: string; // Quality check validator user name same format as "last_name first_name (email)"} +} +export interface SampleRequestModel { + sample_id?: number; // Sample internal identifier + sample_name?: string; // Sample name + comment?: string; // Optional comment + instrument_serial_number?: string; // Instrument serial number + optional_structure_id?: string; // Optional id (Argo / Glider) + max_pressure?: number; // Maximum pressure (in relevant unit) + integration_time?: number; // Integration time (in seconds or relevant unit) + station_id?: string; // Station identifier + sampling_date?: string; // Sampling date in ISO format + latitude?: number; // Latitude (in decimal degrees) [DD.DDDD] (- for South) + longitude?: number; // Longitude (in decimal degrees) [DDD.DDDD] (- for West) + wind_direction?: number; // Wind direction (in degrees) + wind_speed?: number; // Wind speed (in relevant unit) + sea_state?: string; // Description or classification of sea state + nebulousness?: number; // Cloud coverage percentage (0-100) + bottom_depth?: number; // Bottom depth (in meters or relevant unit) + instrument_operator_email?: string; // Operator's email + filename?: string; // source file name + + filter_first_image?: string; // First image + filter_last_image?: string; // Last image + + instrument_settings_acq_gain?: number; // Acquisition gain + instrument_settings_acq_description?: string | undefined; // Acquisition description + instrument_settings_acq_task_type?: string | undefined; // Acquisition task type + instrument_settings_acq_choice?: string | undefined; // Acquisition choice + instrument_settings_acq_disk_type?: string | undefined; // Acquisition disk type + instrument_settings_acq_appendices_ratio?: number; // Acquisition ratio + instrument_settings_acq_xsize?: number | undefined; // Acquisition X size (in pixels or relevant unit) + instrument_settings_acq_ysize?: number | undefined; // Acquisition Y size (in pixels or relevant unit) + instrument_settings_acq_erase_border?: number | undefined; // Acquisition erase border (0/1 boolean) + instrument_settings_acq_threshold?: number; // Acquisition threshold value + instrument_settings_process_datetime?: string | undefined; // Process date and time (ISO format) + instrument_settings_process_gamma?: number; // Process gamma value + instrument_settings_images_post_process?: string; // Image post-processing details + instrument_settings_aa?: number; // Aa value for UVP6 (divided by 10^6) + instrument_settings_exp?: number; // Exp value + instrument_settings_image_volume_l?: number; // Image volume in liters + instrument_settings_pixel_size_mm?: number; // Pixel size in millimeters + instrument_settings_depth_offset_m?: number; // Depth offset in meters + instrument_settings_particle_minimum_size_pixels?: number | undefined; // Particle minimum size in pixels + instrument_settings_vignettes_minimum_size_pixels?: number | undefined; // Vignettes minimum size in pixels + instrument_settings_particle_minimum_size_esd?: number | undefined; // Particle minimum size in esd + instrument_settings_vignettes_minimum_size_esd?: number | undefined; // Vignettes minimum size in esd + instrument_settings_acq_shutter_speed?: number | undefined; // Acquisition shutter speed (in seconds or relevant unit) + instrument_settings_acq_exposure?: number | undefined; // Acquisition exposure (in seconds or relevant unit) + + visual_qc_validator_user_id?: number; // Quality check validator user identifier + sample_type_id?: number; // Sample type depth or time + project_id?: number; // Project identifier +} + +export interface MinimalSampleRequestModel { + sample_id?: number; // Sample internal identifier + sample_name?: string; // Sample name + project_id?: number; +} +export interface SampleIdModel { + sample_id: number; +} +export interface PrivateSampleUpdateModel { + sample_id: number; // Sample internal identifier +} +//TODO +export interface PrivateSampleUpdateModel { +} +//TODO +export interface PublicSampleUpdateModel extends PrivateSampleUpdateModel { +} + +/* COMPUTE VIGNETTES */ +export interface ComputeVignettesModel { + gamma: number; // gamma coefficiant of the gamma correction + invert: string; // invert image (black => white) : Values Y/N case insensitive + scalebarsize_mm: number; // size in millimeter of the scale bar + keeporiginal: string; // load original image in Ecotaxa in adition of the computer vignette : Values Y/N case insensitive + fontcolor: string; // color of the text in the footer (black or white), if white then background is black, else background is 254 + fontheight_px: number; // height of the text in the footer in pixel + footerheight_px: number; // height of the footer in pixel + scale: number; // scale factor to resize the image, 1 = No change , >1 increase size using bucubic method + Pixel_Size: number; // pixel_size in micrometer will be added during sample generation, used to compute scalebar width +} + +/* SAMPLE TYPE */ +export interface SampleTypeModel { + sample_type_id: number; + sample_type_label: string; // Time série OR Depth profile + sample_type_description: string; +} +export interface SampleTypeRequestModel { + sample_type_id?: number; + sample_type_label?: string; // Time série OR Depth profile + sample_type_description?: string; +} +/* VISUAL QC STATUS */ +export interface VisualQualityCheckStatusModel { + visual_qc_status_id: number; + visual_qc_status_label: string; // Can be pending, validated, rejected +} +export interface VisualQualityCheckStatusRequestModel { + visual_qc_status_id?: number; + visual_qc_status_label?: string; // Can be pending, validated, rejected } + +/* HEADER */ export interface PublicHeaderSampleResponseModel { sample_name: string, raw_file_name: string, station_id: string, - first_image: string, - last_image: string, + first_image: number, + last_image: number, comment: string, qc_lvl1: boolean, qc_lvl1_comment: string, } export interface HeaderSampleModel { - cruise: string; - ship: string; - filename: string; - profileId: string; - bottomDepth: string; - ctdRosetteFilename: string; - latitude: string; - longitude: string; - firstImage: string; - volImage: string; - aa: string; - exp: string; - dn: string; - windDir: string; - windSpeed: string; - seaState: string; - nebulousness: string; - comment: string; - endImg: string; - yoyo: string; - stationId: string; - sampleType: string | undefined; - integrationTime: string | undefined; - argoId: string | undefined; - pixelSize: string | undefined; - sampleDateTime: string | undefined; + cruise: string, + ship: string, + filename: string, + profileId: string, + bottomDepth: number, + ctdRosetteFilename: string, + latitude: string, + longitude: string, + firstImage: number, + volImage: number, + aa: number, + exp: number, + dn: number, + windDir: number, + windSpeed: number, + seaState: string, + nebulousness: number, + comment: string, + endImg: number, + yoyo: string, + stationId: string, + sampleType: string, + integrationTime: number, + argoId: string, + pixelSize: number, + sampleDateTime: string, +} + +/* METADATA INI */ +export interface MetadataIniSampleModel { + sampleType: string; + latitude_raw: string; + longitude_raw: string; + + sample_name: string; // Sample name + comment: string; // Optional comment + instrument_serial_number: string; // Instrument serial number + optional_structure_id?: string; // Optional id (Argo / Glider) + integration_time?: number; // Integration time (in seconds or relevant unit) + station_id: string; // Station identifier + sampling_date: string; // Sampling date in ISO format + wind_direction: number; // Wind direction (in degrees) + wind_speed: number; // Wind speed (in relevant unit) + sea_state: string; // Description or classification of sea state + nebulousness: number; // Cloud coverage percentage (0-100) + bottom_depth: number; // Bottom depth (in meters or relevant unit) + instrument_operator_email: string; // Operator's email + filename: string; // source file name + + filter_first_image: string; // First image + filter_last_image: string; // Last image + + instrument_settings_acq_gain: number; // Acquisition gain + instrument_settings_acq_description: string | undefined; // Acquisition description + instrument_settings_acq_task_type: string | undefined; // Acquisition task type + instrument_settings_acq_choice: string | undefined; // Acquisition choice + instrument_settings_acq_disk_type: string | undefined; // Acquisition disk type + instrument_settings_acq_appendices_ratio: number; // Acquisition ratio + instrument_settings_acq_xsize: number | undefined; // Acquisition X size (in pixels or relevant unit) + instrument_settings_acq_ysize: number | undefined; // Acquisition Y size (in pixels or relevant unit) + instrument_settings_acq_erase_border: number | undefined; // Acquisition erase border (0/1 boolean) + instrument_settings_acq_threshold: number; // Acquisition threshold value + instrument_settings_process_datetime: string | undefined; // Process date and time (ISO format) + instrument_settings_images_post_process: string; // Image post-processing details + instrument_settings_aa: number; // Aa value for UVP6 (divided by 10^6) + instrument_settings_exp: number; // Exp value + instrument_settings_image_volume_l: number; // Image volume in liters + instrument_settings_pixel_size_mm: number; // Pixel size in millimeters + instrument_settings_depth_offset_m: number; // Depth offset in meters + instrument_settings_particle_minimum_size_pixels: number | undefined; // Particle minimum size in pixels + instrument_settings_vignettes_minimum_size_pixels: number | undefined; // Vignettes minimum size in pixels + instrument_settings_particle_minimum_size_esd: number | undefined; // Particle minimum size in esd + instrument_settings_vignettes_minimum_size_esd: number | undefined; // Vignettes minimum size in esd + instrument_settings_acq_shutter: number | undefined; // Acquisition shutter + instrument_settings_acq_shutter_speed: number | undefined; // Acquisition shutter speed (in seconds or relevant unit) + instrument_settings_acq_exposure: number | undefined; // Acquisition exposure (in seconds or relevant unit) +} + +/* FOR export */ +export interface ExportSampleModel extends PublicSampleModel { + filter_last_image_used: string,//TODO à déplacer : metadata à calculer au moment de export + filter_removed_empty_slice: boolean,//TODO à déplacer : metadata à calculer au moment de export + filter_filtered_rows: number,//TODO à déplacer : metadata à calculer au moment de export + instrument_settings_acq_descent_filter: string,//TODO à déplacer } \ No newline at end of file diff --git a/src/domain/interfaces/repositories/sample-repository.ts b/src/domain/interfaces/repositories/sample-repository.ts index 74facb1..af44160 100644 --- a/src/domain/interfaces/repositories/sample-repository.ts +++ b/src/domain/interfaces/repositories/sample-repository.ts @@ -1,7 +1,18 @@ -import { PublicSampleResponseModel } from "../../entities/sample"; +import { PublicHeaderSampleResponseModel, PublicSampleModel, SampleIdModel, SampleRequestCreationModel, SampleRequestModel, SampleTypeModel, SampleTypeRequestModel, VisualQualityCheckStatusModel, VisualQualityCheckStatusRequestModel } from "../../entities/sample"; +import { PreparedSearchOptions, SearchResult } from "../../entities/search"; export interface SampleRepository { + formatSampleToImport(base_sample: Partial, instrument_model: string): Promise; + createSample(sample: SampleRequestCreationModel): Promise; + createManySamples(samples: SampleRequestCreationModel[]): Promise; ensureFolderExists(root_folder_path: string): Promise; - listImportableSamples(root_folder_path: string, instrument_model: string): Promise; + listImportableSamples(root_folder_path: string, instrument_model: string, dest_folder: string, project_id: number): Promise; copySamplesToImportFolder(source_folder: string, dest_folder: string, samples_names_to_import: string[]): Promise + deleteSamplesFromImportFolder(dest_folder: string, samples_names_to_import: string[]): Promise + getSample(sample: SampleRequestModel): Promise; + deleteSample(sample: SampleIdModel): Promise; + deleteSampleFromStorage(sample_name: string, project_id: number): Promise; + standardGetSamples(options: PreparedSearchOptions): Promise>; + getSampleType(sample_type: SampleTypeRequestModel): Promise; + getVisualQCStatus(visual_qc_status: VisualQualityCheckStatusRequestModel): Promise; } \ No newline at end of file diff --git a/src/domain/interfaces/repositories/task-repository.ts b/src/domain/interfaces/repositories/task-repository.ts index f58eb9e..beb7174 100644 --- a/src/domain/interfaces/repositories/task-repository.ts +++ b/src/domain/interfaces/repositories/task-repository.ts @@ -23,4 +23,5 @@ export interface TaskRepository { getTasksByUser(user: UserRequestModel): Promise; getLogFileTask(task_id: number): Promise; failedTask(task_id: number, error: Error): Promise; + logMessage(task_log_file_path: string | undefined, message: string): Promise; } diff --git a/src/domain/interfaces/use-cases/sample/delete-sample.ts b/src/domain/interfaces/use-cases/sample/delete-sample.ts new file mode 100644 index 0000000..8a55965 --- /dev/null +++ b/src/domain/interfaces/use-cases/sample/delete-sample.ts @@ -0,0 +1,4 @@ +import { UserUpdateModel } from "../../../entities/user"; +export interface DeleteSampleUseCase { + execute(current_user: UserUpdateModel, sample_id_to_delete: number, project_id: number): Promise; +} \ No newline at end of file diff --git a/src/domain/interfaces/use-cases/sample/list-importable-samples.ts b/src/domain/interfaces/use-cases/sample/list-importable-samples.ts index 777d1db..46ab958 100644 --- a/src/domain/interfaces/use-cases/sample/list-importable-samples.ts +++ b/src/domain/interfaces/use-cases/sample/list-importable-samples.ts @@ -1,6 +1,6 @@ -import { PublicSampleResponseModel } from "../../../entities/sample"; +import { PublicHeaderSampleResponseModel } from "../../../entities/sample"; import { UserUpdateModel } from "../../../entities/user"; export interface ListImportableSamplesUseCase { - execute(current_user: UserUpdateModel, project_id: number): Promise; + execute(current_user: UserUpdateModel, project_id: number): Promise; } \ No newline at end of file diff --git a/src/domain/interfaces/use-cases/sample/search-samples.ts b/src/domain/interfaces/use-cases/sample/search-samples.ts new file mode 100644 index 0000000..2944d9e --- /dev/null +++ b/src/domain/interfaces/use-cases/sample/search-samples.ts @@ -0,0 +1,6 @@ +import { FilterSearchOptions, SearchInfo, SearchOptions } from "../../../entities/search"; +import { PublicSampleModel } from "../../../entities/sample"; +import { UserUpdateModel } from "../../../entities/user"; +export interface SearchSamplesUseCase { + execute(current_user: UserUpdateModel, options: SearchOptions, filters: FilterSearchOptions[], project_id?: number): Promise<{ samples: PublicSampleModel[], search_info: SearchInfo }>; +} \ No newline at end of file diff --git a/src/domain/repositories/sample-repository.ts b/src/domain/repositories/sample-repository.ts index 3e89529..a97b0d9 100644 --- a/src/domain/repositories/sample-repository.ts +++ b/src/domain/repositories/sample-repository.ts @@ -1,30 +1,520 @@ -// import { SampleDataSource } from "../../data/interfaces/data-sources/sample-data-source"; +import { SampleDataSource } from "../../data/interfaces/data-sources/sample-data-source"; // import { InstrumentModelResponseModel } from "../entities/instrument_model"; // import { PublicPrivilege } from "../entities/privilege"; -// import { SampleRequestCreationModel, SampleRequestModel, SampleUpdateModel, SampleResponseModel, PublicSampleResponseModel, PublicSampleRequestCreationModel } from "../entities/sample"; +// import { SampleRequestCreationModel, SampleRequestModel, SampleUpdateModel, SampleResponseModel, PublicHeaderSampleResponseModel, PublicSampleRequestCreationModel } from "../entities/sample"; // import { PreparedSearchOptions, SearchResult } from "../entities/search"; // import { SampleRepository } from "../interfaces/repositories/sample-repository"; -import { HeaderSampleModel, PublicHeaderSampleResponseModel } from "../entities/sample"; +import { ComputeVignettesModel, HeaderSampleModel, MetadataIniSampleModel, MinimalSampleRequestModel, PublicHeaderSampleResponseModel, PublicSampleModel, SampleIdModel, SampleRequestCreationModel, SampleRequestModel, SampleTypeModel, SampleTypeRequestModel, VisualQualityCheckStatusModel, VisualQualityCheckStatusRequestModel } from "../entities/sample"; +import { PreparedSearchOptions, SearchResult } from "../entities/search"; import { SampleRepository } from "../interfaces/repositories/sample-repository"; import { promises as fs } from 'fs'; +//import fs from 'fs'; // Correct import for `createReadStream` +//import { parse, Options } from 'csv-parse'; // Use named import for `parse` + import path from 'path'; +import yauzl from 'yauzl'; + + export class SampleRepositoryImpl implements SampleRepository { - //sampleDataSource: SampleDataSource + sampleDataSource: SampleDataSource + DATA_STORAGE_FS_STORAGE: string + + // TODO move to a search repository + order_by_allow_params: string[] = ["asc", "desc"] + filter_operator_allow_params: string[] = ["=", ">", "<", ">=", "<=", "<>", "IN", "LIKE"] + + constructor(sampleDataSource: SampleDataSource, DATA_STORAGE_FS_STORAGE: string) { + this.sampleDataSource = sampleDataSource + this.DATA_STORAGE_FS_STORAGE = DATA_STORAGE_FS_STORAGE + } + + async formatSampleToImport(base_sample: Partial, instrument_model: string): Promise { + + const file_system_storage_project_folder = this.DATA_STORAGE_FS_STORAGE + base_sample.project_id || ''; + + // foreach sample in samples_names_to_import + const sample_to_return = await this.getSampleFromFsStorage(file_system_storage_project_folder, base_sample, instrument_model); + return sample_to_return; + } - // // TODO move to a search repository - // order_by_allow_params: string[] = ["asc", "desc"] - // filter_operator_allow_params: string[] = ["=", ">", "<", ">=", "<=", "<>", "IN", "LIKE"] + async getSampleFromFsStorage(file_system_storage_project_folder: string, base_sample: Partial, instrument_model: string): Promise { + let sample_fss = {}; - // constructor(sampleDataSource: SampleDataSource) { - // this.sampleDataSource = sampleDataSource + if (instrument_model.startsWith('UVP6')) { + // read from file_system_storage_project_folder/ecodata and return the list of samples + sample_fss = await this.getSampleFromFsStorageUVP6(file_system_storage_project_folder, base_sample.sample_name as string); + } + // else if (instrument_model.startsWith('UVP5')) { + // // read from file_system_storage_project_folder/work and return the list of samples + // sample_fss = await this.getSampleFromFsStorageUVP5(file_system_storage_project_folder, base_sample.sample_name as string); + // } + const sample = this.constructSampleFromBaseAndFsStorage(sample_fss, base_sample); + + return sample; + } + + // async getSampleFromFsStorageUVP5(file_system_storage_project_folder: string, sample_name: string): Promise { + + // const sample: Partial = { + // sample_name: ini_content.sample_metadata['profileid'] as string, + // comment: ini_content.sample_metadata['comment'] as string, + // instrument_serial_number: ini_content.HW_CONF['Camera_ref'] as string, + // optional_structure_id: ini_content.sample_metadata['argoid'] as string, + // max_pressure: this.computeMaxPressure(),//TODO à calculer + // integration_time: ini_content.sample_metadata['integrationtime'] as number, + // station_id: ini_content.sample_metadata['stationid'] as string, + // sampling_date: ini_content.sample_metadata['sampledatetime'] as string, + // latitude: this.computeLatitude(ini_content.sample_metadata['latitude'] as number), + // longitude: this.computeLongitude(ini_content.sample_metadata['longitude'] as number), + // wind_direction: ini_content.sample_metadata['winddir'] as number, + // wind_speed: ini_content.sample_metadata['windspeed'] as number, + // sea_state: ini_content.sample_metadata['seastate'] as string, + // nebulousness: ini_content.sample_metadata['nebuloussness'] as number, + // bottom_depth: ini_content.sample_metadata['bottom_depth'] as number, + // instrument_operator_email: ini_content.HW_CONF['Operator_email'] as string, + // filename: ini_content.sample_metadata['filename'] as string, + // filter_first_image: ini_content.sample_metadata['firstimage'] as string, + // filter_last_image: ini_content.sample_metadata['endimg'] as string, + // instrument_settings_acq_gain: ini_content.ACQ_CONF['Gain'] as number, + // instrument_settings_acq_description: ini_content['???'] as string || "#TODO",//TODO seulement pour l'uvp5 + // instrument_settings_acq_task_type: ini_content['???'] as string || "#TODO",// TODO seulement pour l'uvp5 + // sample_type_id: this.getSampleTypeFromLetter(ini_content.sample_metadata['sampletype']), //TODO à calculer checket si T ou D si non erreur + // instrument_settings_acq_choice: ini_content['???'] as string || "#TODO",//TODO seulement pour l'uvp5 + // instrument_settings_acq_disk_type: ini_content['???'] as string || "#TODO", // TODO seulement pour l'uvp5 + // instrument_settings_acq_appendices_ratio: ini_content.ACQ_CONF['Appendices_ratio'] as number, + // instrument_settings_acq_xsize: ini_content['???'] as number || -1,//TODO seulement pour l'uvp5 (fichier uvp5_configurationdata.txt) + // instrument_settings_acq_ysize: ini_content['???'] as number || -1,//TODO seulement pour l'uvp5 (fichier uvp5_configurationdata.txt) + // instrument_settings_acq_erase_border: ini_content['???'] as number || -1,//TODO seulement pour l'uvp5 + // instrument_settings_acq_threshold: ini_content.HW_CONF['Threshold'] as number, + // instrument_settings_process_datetime: "ini_content['???'] as string || ",//TODO seulement pour l'uvp5 + // instrument_settings_process_gamma: this.getGammaForUVP6(),//TODO à aller chercher image.zip compute_vignette.txt + // instrument_settings_images_post_process: "uvpapp", + // instrument_settings_aa: ini_content.HW_CONF['Aa'] as number, + // instrument_settings_exp: ini_content.HW_CONF['Exp'] as number, + // instrument_settings_image_volume_l: ini_content.HW_CONF['Image_volume'] as number, + // instrument_settings_pixel_size_mm: ini_content.HW_CONF['Pixel_Size'] as number, + // instrument_settings_depth_offset_m: ini_content.HW_CONF['Pressure_offset'] as number, + // instrument_settings_particle_minimum_size_pixels: ini_content['??'] as number || -1,//TODO seulement pour l'uvp5 + // instrument_settings_vignettes_minimum_size_pixels: ini_content.ACQ_CONF['??'] as number, //TODO seulement pour l'uvp5 + // instrument_settings_particle_minimum_size_esd: ini_content.ACQ_CONF['Limit_lpm_detection_size'] as number || -1,//TODO seulement pour l'uvp6 + // instrument_settings_vignettes_minimum_size_esd: ini_content.ACQ_CONF['Vignetting_lower_limit_size'] as number, //TODO seulement pour l'uvp6 + // instrument_settings_acq_shutter_speed: ini_content.HW_CONF['???'] as number, // UVP5SD + // instrument_settings_acq_exposure: ini_content['???'] as number || -1,// TODO UVP5HD + // instrument_settings_acq_shutter: ini_content['Shutter'] as number || -1,// TODO UVP6, + // } + //////////////////////////////// + // //read from tsv + // const tsvPath = path.join(file_system_storage_project_folder, "work", sample_name, `ecotaxa_${sample_name}.tsv`); + // type RowType = { + // name: string; + // age: number; + // city: string; + // }; + + // const options: Options = { + // delimiter: '\t', + // columns: true, + // }; + // let rowCount = 0; + // const rows: RowType[] = []; // Array to store the rows + + // return new Promise((resolve, reject) => { + // const stream = fs.createReadStream(tsvPath) + // .pipe(parse(options)) + // .on('data', (row: RowType) => { + // if (rowCount < 2) { + // console.log('Row:', row); + // rows.push(row); // Add the row to the array + // rowCount++; + // } else { + // // Stop reading further data once two rows are processed + // stream.destroy(); + // } + // }) + // .on('end', () => { + // console.log('File processing completed.'); + // resolve(rows); // Return the array of rows + // }) + // .on('error', (err) => { + // console.error('Error reading the file:', err); + // reject(err); + // }); + // }); // } + async getSampleFromFsStorageUVP6(file_system_storage_project_folder: string, sample_name: string): Promise> { + + /*** + * + fetch data from metadata.ini: + ***** + process already fetch data: + latitude: this.computeLatitude(ini_content.sample_metadata['latitude'] as number), + longitude: this.computeLongitude(ini_content.sample_metadata['longitude'] as number), + + sample_type_id: this.getSampleTypeFromLetter(ini_content.sample_metadata['sampletype']), //TODO à calculer checket si T ou D si non erreur + + go to another file to get the data: + max_pressure: this.computeMaxPressure(),//TODO à calculer + instrument_settings_process_gamma: this.getGammaForUVP6(),//TODO à aller chercher image.zip compute_vignette.txt + + * + ***/ + // Complete/reprocess the sample object + // Get sample info from metadata.ini + const sample_metadata_ini = await this.getSampleFromMetadataIni(file_system_storage_project_folder, sample_name); + // Process sample_type_id + const sample_type_id = await this.computeSampleTypeId(sample_metadata_ini); + // Process latitude and longitude + const coords = this.computeLatitudeAndLongitude(sample_metadata_ini); + + // Compute max_pressure + const max_pressure = await this.computeMaxPressure(sample_metadata_ini, file_system_storage_project_folder, sample_name); + // Get instrument_settings_process_gamma + const instrument_settings_process_gamma = await this.getInstrumentSettingsProcessGamma(sample_metadata_ini, file_system_storage_project_folder, sample_name); + + + // Construct the sample object + delete (sample_metadata_ini as any).sampleType; + delete (sample_metadata_ini as any).latitude_raw; + delete (sample_metadata_ini as any).longitude_raw; + + const sample_to_return: Partial = { + ...sample_metadata_ini, + sample_type_id, + latitude: coords.latitude, + longitude: coords.longitude, + max_pressure, + instrument_settings_process_gamma + }; + return sample_to_return; + } + + async getInstrumentSettingsProcessGamma(sample: MetadataIniSampleModel, file_system_storage_project_folder: string, sample_name: string): Promise { + // todo if no vignettes generated return undefined + if (sample.comment === 'no vignettes generated') { + return undefined; + } + // else return the gamma + const gamma = (await this.readComputeVignettes(file_system_storage_project_folder, sample_name)).gamma; + return gamma; + } + + async readComputeVignettes(file_system_storage_project_folder: string, sample_name: string): Promise { + return new Promise((resolve, reject) => { + // Define the path to the .zip file based on the project folder and sample name + const zipPath = path.join(file_system_storage_project_folder, sample_name, `${sample_name}_Images.zip`); + + // Open the zip file without extracting it to disk + yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => { + if (err || !zipfile) { + return reject(err || new Error('Failed to open zip file')); + } + + // Start reading entries in the zip file + zipfile.readEntry(); + + // Handle each entry found in the zip file + zipfile.on('entry', (entry) => { + // Check if the current entry is the metadata.ini file + if (entry.fileName === 'compute_vignette.txt') { + zipfile.openReadStream(entry, (err, readStream) => { + if (err || !readStream) { + return reject(err || new Error('Failed to open read stream')); + } + + let data = ''; + // Collect data chunks from the read stream + readStream.on('data', (chunk) => { + data += chunk; + }); + + // When the read stream ends, parse the collected data manually + readStream.on('end', () => { + try { + const parsedData = this.parseComputeVignettes(data); + resolve(parsedData); + } catch (parseError) { + reject(parseError); + } + }); + }); + } else { + zipfile.readEntry(); + } + }); + + zipfile.on('end', () => { + reject(new Error('metadata.ini not found in zip file')); + }); + }); + }); + } + + parseComputeVignettes(data: string): ComputeVignettesModel { + const result: Partial = {}; + const lines = data.split('\n'); + + for (const line of lines) { + const [key, value] = line.split('=').map(part => part.trim()); + + switch (key) { + case 'gamma': + result.gamma = parseFloat(value); + break; + case 'invert': + result.invert = value.toLowerCase(); + break; + case 'scalebarsize_mm': + result.scalebarsize_mm = parseFloat(value); + break; + case 'keeporiginal': + result.keeporiginal = value.toLowerCase(); + break; + case 'fontcolor': + result.fontcolor = value.toLowerCase(); + break; + case 'fontheight_px': + result.fontheight_px = parseInt(value) + break; + case 'footerheight_px': + result.footerheight_px = parseInt(value) + break; + case 'scale': + result.scale = parseFloat(value); + break; + case 'Pixel_Size': + result.Pixel_Size = parseFloat(value); + break; + default: + break; + } + } + + return result as ComputeVignettesModel; + } + + + computeMaxPressure(sample: MetadataIniSampleModel, file_system_storage_project_folder: string, sample_name: string): number { + console.log(sample, file_system_storage_project_folder, sample_name) + return 0; + } + + computeLatitudeAndLongitude(sample: MetadataIniSampleModel): { latitude: number, longitude: number } { + // Compute latitude and longitude + const latitude = this.convTextDegreeDotMinuteToDecimalDegree(sample.latitude_raw, 'uvp6'); + const longitude = this.convTextDegreeDotMinuteToDecimalDegree(sample.longitude_raw, 'uvp6'); + + return { latitude, longitude }; + } + + + // Convert a latitude or longitude string to a decimal degree float. + // Possible formats: + // DD°MM SS + // DD.MMMMM: MMMMM = Minutes /100, between 0.0 and 0.6 (historical UVP format) + // DD.FFFFF: FFFFF = Fraction of degrees + convTextDegreeDotMinuteToDecimalDegree(v: string, instrumtype: string): number { + + const degreeMinuteSecondRegex = /(-?\d+)°(\d+) (\d+)/; + const match = degreeMinuteSecondRegex.exec(v); + + if (match) { // Format DDD°MM SSS + const degrees = parseFloat(match[1]); + const minutes = parseFloat(match[2]); + const seconds = parseFloat(match[3]); + + const minuteFraction = minutes + seconds / 60; + const decimalDegrees = degrees + Math.sign(degrees) * (minuteFraction / 60); + return parseFloat(decimalDegrees.toFixed(5)); + } else { + if (instrumtype === 'uvp5') { + const floatValue = this._ToFloat(v); + const integerPart = Math.floor(floatValue); + const fractionPart = floatValue - integerPart; + return parseFloat((integerPart + fractionPart / 0.6).toFixed(5)); + } else { + return parseFloat(this._ToFloat(v).toFixed(5)); + } + } + } + + // Helper function to convert a string to float + _ToFloat(v: string): number { + return parseFloat(v); + } + + + async computeSampleTypeId(sample: MetadataIniSampleModel): Promise { + let sample_type_id: number | undefined; + + //throw error if unknown sample type + if (sample.sampleType === undefined) throw new Error("Undefined sample type"); + + // Compute sample_type_id + if (sample.sampleType == "T") { + sample_type_id = (await this.getSampleType({ sample_type_label: "Time" }))?.sample_type_id; + } else if (sample.sampleType == "D") { + sample_type_id = (await this.getSampleType({ sample_type_label: "Depth" }))?.sample_type_id; + } else throw new Error("Unknown sample type"); + + if (sample_type_id === undefined) throw new Error("Sample type not found"); + return sample_type_id + } + + getSampleFromMetadataIni(file_system_storage_project_folder: string, sample_name: string): Promise { + return new Promise((resolve, reject) => { + // Define the path to the .zip file based on the project folder and sample name + const zipPath = path.join(file_system_storage_project_folder, sample_name, `${sample_name}_Particule.zip`); + + // Open the zip file without extracting it to disk + yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => { + if (err || !zipfile) { + return reject(err || new Error('Failed to open zip file')); + } + + // Start reading entries in the zip file + zipfile.readEntry(); + + // Handle each entry found in the zip file + zipfile.on('entry', (entry) => { + // Check if the current entry is the metadata.ini file + if (entry.fileName === 'metadata.ini') { + zipfile.openReadStream(entry, (err, readStream) => { + if (err || !readStream) { + return reject(err || new Error('Failed to open read stream')); + } + + let data = ''; + // Collect data chunks from the read stream + readStream.on('data', (chunk) => { + data += chunk; + }); + + // When the read stream ends, parse the collected data manually + readStream.on('end', () => { + try { + const parsedData = this.parseIniContent(data); + resolve(parsedData); + } catch (parseError) { + reject(parseError); + } + }); + }); + } else { + zipfile.readEntry(); + } + }); + + zipfile.on('end', () => { + reject(new Error('metadata.ini not found in zip file')); + }); + }); + }); + } + + parseIniContent(data: string): MetadataIniSampleModel { + const ini_content: any = {}; + let currentSection: string | null = null; + + // Split the data into lines and process each line + const lines = data.split(/\r?\n/); + lines.forEach((line) => { + line = line.trim(); + + // Ignore empty lines or comments + if (!line || line.startsWith(';') || line.startsWith('#')) { + return; + } + + // Check if the line is a section header + if (line.startsWith('[') && line.endsWith(']')) { + currentSection = line.slice(1, -1).trim(); + ini_content[currentSection] = {}; + } + // Otherwise, it's a key-value pair + else if (currentSection) { + const [key, value] = line.split('=').map((part) => part.trim()); + if (key) { + // Convert the value to a number if possible, otherwise keep as string + (ini_content[currentSection] as any)[key] = isNaN(Number(value)) ? value : Number(value); + } + } + }); + + const sample: MetadataIniSampleModel = { + sample_name: ini_content.sample_metadata['profileid'] as string, + comment: ini_content.sample_metadata['comment'] as string, + instrument_serial_number: ini_content.HW_CONF['Camera_ref'] as string, + optional_structure_id: ini_content.sample_metadata['argoid'] as string, + integration_time: ini_content.sample_metadata['integrationtime'] as number, + station_id: ini_content.sample_metadata['stationid'] as string, + sampling_date: ini_content.sample_metadata['sampledatetime'] as string, + wind_direction: ini_content.sample_metadata['winddir'] as number, + wind_speed: ini_content.sample_metadata['windspeed'] as number, + sea_state: ini_content.sample_metadata['seastate'] as string, + nebulousness: ini_content.sample_metadata['nebuloussness'] as number, + bottom_depth: ini_content.sample_metadata['bottom_depth'] as number, + instrument_operator_email: ini_content.HW_CONF['Operator_email'] as string, + filename: ini_content.sample_metadata['filename'] as string, + filter_first_image: ini_content.sample_metadata['firstimage'] as string, + filter_last_image: ini_content.sample_metadata['endimg'] as string, + instrument_settings_acq_gain: ini_content.ACQ_CONF['Gain'] as number, + instrument_settings_acq_description: undefined, + instrument_settings_acq_task_type: undefined, + instrument_settings_acq_choice: undefined, + instrument_settings_acq_disk_type: undefined, + instrument_settings_acq_appendices_ratio: ini_content.ACQ_CONF['Appendices_ratio'] as number, + instrument_settings_acq_xsize: undefined, + instrument_settings_acq_ysize: undefined, + instrument_settings_acq_erase_border: undefined, + instrument_settings_acq_threshold: ini_content.HW_CONF['Threshold'] as number, + instrument_settings_process_datetime: undefined, + instrument_settings_images_post_process: "uvpapp", + instrument_settings_aa: ini_content.HW_CONF['Aa'] as number, + instrument_settings_exp: ini_content.HW_CONF['Exp'] as number, + instrument_settings_image_volume_l: ini_content.HW_CONF['Image_volume'] as number, + instrument_settings_pixel_size_mm: ini_content.HW_CONF['Pixel_Size'] as number, + instrument_settings_depth_offset_m: ini_content.HW_CONF['Pressure_offset'] as number, + instrument_settings_particle_minimum_size_pixels: undefined, + instrument_settings_vignettes_minimum_size_pixels: undefined, + instrument_settings_particle_minimum_size_esd: ini_content.ACQ_CONF['Limit_lpm_detection_size'] as number, + instrument_settings_vignettes_minimum_size_esd: ini_content.ACQ_CONF['Vignetting_lower_limit_size'] as number, + instrument_settings_acq_shutter_speed: undefined, + instrument_settings_acq_exposure: undefined, + instrument_settings_acq_shutter: undefined, + + // these will be reprocessed + latitude_raw: ini_content.sample_metadata['latitude'], + longitude_raw: ini_content.sample_metadata['longitude'], + + sampleType: ini_content.sample_metadata['sampletype'] + } + + return sample; + } + + constructSampleFromBaseAndFsStorage(sample_fss: Partial, base_sample: Partial): SampleRequestCreationModel { + const sample: SampleRequestCreationModel = { ...base_sample, ...sample_fss } as SampleRequestCreationModel; + return sample; + } + + async createSample(sample: SampleRequestCreationModel): Promise { + const result = await this.sampleDataSource.createOne(sample) + return result; + } + + async createManySamples(samples: SampleRequestCreationModel[]): Promise { + const result = await this.sampleDataSource.createMany(samples) + return result; + } + async ensureFolderExists(root_folder_path: string): Promise { const folderPath = path.join(root_folder_path); @@ -35,29 +525,73 @@ export class SampleRepositoryImpl implements SampleRepository { } } - async listImportableSamples(root_folder_path: string, instrument_model: string): Promise { + async listImportableSamples(root_folder_path: string, instrument_model: string, dest_folder: string, project_id: number): Promise { + // List importable samples from root_folder_path let samples: PublicHeaderSampleResponseModel[] = []; const folderPath = path.join(root_folder_path); - // read from folderPath/meta/*header*.txt and return the list of samples + // Read from folderPath/meta/*header*.txt and return the list of samples const meta_header_samples = await this.getSamplesFromHeaders(folderPath); if (instrument_model.startsWith('UVP6')) { - // read from folderPath/ecodata and return the list of samples + // Read from folderPath/ecodata and return the list of samples const samples_ecodata = await this.getSamplesFromEcodata(folderPath); samples = await this.setupSamples(meta_header_samples, samples_ecodata, "ecodata"); } else if (instrument_model.startsWith('UVP5')) { - // read from folderPath/work and return the list of samples + // Read from folderPath/work and return the list of samples const samples_work = await this.getSamplesFromWork(folderPath); samples = await this.setupSamples(meta_header_samples, samples_work, "work"); } + + // Filter samples that are not already in the project folder + const fs_imported_samples_name = await this.getSamplesFromFsStorage(dest_folder); + const fs_filtered_samples = samples.filter(sample => { + const existingSample = fs_imported_samples_name.includes(sample.sample_name); + return !existingSample; // keep if sample is not found + }); + + + // Filter samples that are not already in the database + const options: PreparedSearchOptions = { + filter: [ + { field: 'project_id', operator: '=', value: project_id } + ], + sort_by: [ + + ], + page: 1, + limit: 10000000 + } + + const db_imported_samples_name = (await this.sampleDataSource.getAll(options)).items.map(sample => sample.sample_name); + + const db_fs_filtered_samples = fs_filtered_samples.filter(sample => { + const existingSample = db_imported_samples_name.includes(sample.sample_name); + return !existingSample; // keep if sample is not found + }); + + return db_fs_filtered_samples; + } + + async getSamplesFromFsStorage(folderPath: string): Promise { + // list folders names in folderPath + const samples: string[] = []; + try { + const files = await fs.readdir(folderPath); + + for (const file of files) { + samples.push(file); + } + } catch (err) { + throw new Error(`Error reading files: ${err.message}`); + } return samples; } // Function to setup samples async setupSamples(meta_header_samples: HeaderSampleModel[], samples: string[], folder: string): Promise { - // flag qc samples to flase if not in both lists, and add qc message + // Flag qc samples to flase if not in both lists, and add qc message const samples_response: PublicHeaderSampleResponseModel[] = []; for (const sample of meta_header_samples) { samples_response.push({ @@ -105,27 +639,27 @@ export class SampleRepositoryImpl implements SampleRepository { ship: fields[1], filename: fields[2], profileId: fields[3], - bottomDepth: fields[4], + bottomDepth: parseFloat(fields[4]), ctdRosetteFilename: fields[5], latitude: fields[6], longitude: fields[7], - firstImage: fields[8], - volImage: fields[9], - aa: fields[10], - exp: fields[11], - dn: fields[12], - windDir: fields[13], - windSpeed: fields[14], + firstImage: parseFloat(fields[8]), + volImage: parseFloat(fields[9]), + aa: parseFloat(fields[10]), + exp: parseFloat(fields[11]), + dn: parseFloat(fields[12]), + windDir: parseFloat(fields[13]), + windSpeed: parseFloat(fields[14]), seaState: fields[15], - nebulousness: fields[16], + nebulousness: parseFloat(fields[16]), comment: fields[17], - endImg: fields[18], + endImg: parseFloat(fields[18]), yoyo: fields[19], stationId: fields[20], sampleType: fields[21], - integrationTime: fields[22], + integrationTime: parseFloat(fields[22]), argoId: fields[23], - pixelSize: fields[24], + pixelSize: parseFloat(fields[24]), sampleDateTime: fields[25] }; return sample; @@ -162,7 +696,6 @@ export class SampleRepositoryImpl implements SampleRepository { return samples; } - // This needs to be inside an async function to use await async ensureSampleFolderDoNotExists(samples_names_to_import: string[], dest_folder: string): Promise { // Ensure that none of the sample folders already exist for (const sample of samples_names_to_import) { @@ -200,6 +733,17 @@ export class SampleRepositoryImpl implements SampleRepository { } } + async deleteSamplesFromImportFolder(dest_folder: string, samples_names_to_import: string[]): Promise { + const base_folder = path.join(__dirname, '..', '..', '..'); + // Iterate over each sample name and delete it + for (const sample of samples_names_to_import) { + const destPath = path.join(base_folder, dest_folder, sample); + + // Delete the sample folder recurcively from destination + await fs.rm(destPath, { recursive: true, force: true }); + } + } + // async createSample(sample: SampleRequestCreationModel): Promise { @@ -207,15 +751,35 @@ export class SampleRepositoryImpl implements SampleRepository { // return result; // } - // async getSample(sample: SampleRequestModel): Promise { - // const result = await this.sampleDataSource.getOne(sample) - // return result; - // } + async getSample(sample: SampleRequestModel): Promise { + // Clean the sample to keep only sample_id, sample_name, project_id if they are defined + const cleaned_sample: MinimalSampleRequestModel = {}; - // async deleteSample(sample: SampleRequestModel): Promise { - // const result = await this.sampleDataSource.deleteOne(sample) - // return result; - // } + if (sample.sample_id !== undefined) cleaned_sample.sample_id = sample.sample_id; + if (sample.sample_name !== undefined) cleaned_sample.sample_name = sample.sample_name; + if (sample.project_id !== undefined) cleaned_sample.project_id = sample.project_id; + + // Get the result from the data source + const result = await this.sampleDataSource.getOne(cleaned_sample); + + return result; + } + + async deleteSample(sample: SampleIdModel): Promise { + const result = await this.sampleDataSource.deleteOne(sample) + return result; + } + + async deleteSampleFromStorage(sample_name: string, project_id: number): Promise { + const folderPath = path.join(this.DATA_STORAGE_FS_STORAGE, `${project_id}`, `${sample_name}`); + try { + console.log(`Deleting sample from storage: ${folderPath}`); + await fs.rm(folderPath, { recursive: true, force: true }); + return 1; + } catch (error) { + throw new Error(`Error deleting sample from storage: ${error.message}`); + } + } // private async updateSample(sample: SampleUpdateModel, params: string[]): Promise { // const filteredSample: Partial = {}; @@ -245,62 +809,87 @@ export class SampleRepositoryImpl implements SampleRepository { // } // async standardUpdateSample(sample: SampleUpdateModel): Promise { - // const params_restricted = ["sample_id", "root_folder_path", "sample_title", "sample_acronym", "sample_description", "sample_information", "cruise", "ship", "data_owner_name", "data_owner_email", "operator_name", "operator_email", "chief_scientist_name", "chief_scientist_email", "override_depth_offset", "enable_descent_filter", "privacy_duration", "visible_duration", "public_duration", "instrument_model", "serial_number"] + // const params_restricted = ["sample_id", "root_folder_path", "sample_title", "sample_acronym", "sample_description", "sample_information", "cruise", "ship", "data_owner_name", "data_owner_email", "operator_email", "chief_scientist_name", "chief_scientist_email", "override_depth_offset", "enable_descent_filter", "privacy_duration", "visible_duration", "public_duration", "instrument_model", "serial_number"] // const updated_sample_nb = await this.updateSample(sample, params_restricted) // return updated_sample_nb // } - // async standardGetSamples(options: PreparedSearchOptions): Promise> { - // // Can be filtered by - // const filter_params_restricted = ["sample_id", "root_folder_path", "sample_title", "sample_acronym", "sample_description", "sample_information", "cruise", "ship", "data_owner_name", "data_owner_email", "operator_name", "operator_email", "chief_scientist_name", "chief_scientist_email", "override_depth_offset", "enable_descent_filter", "privacy_duration", "visible_duration", "public_duration", "instrument_model", "serial_number", "sample_creation_date"] + async standardGetSamples(options: PreparedSearchOptions): Promise> { + // Can be filtered by + const filter_params_restricted = ["sample_id", "sample_name", "comment", "instrument_serial_number", "optional_structure_id", "max_pressure", "integration_time", "station_id", "sampling_date", "latitude", "longitude", "wind_direction", "wind_speed", "sea_state", "nebulousness", "bottom_depth", "operator_email", "filename", "filter_first_image", "filter_last_image", "instrument_settings_acq_gain", "instrument_settings_acq_description", "instrument_settings_acq_task_type", "instrument_settings_acq_choice", "instrument_settings_acq_disk_type", "instrument_settings_acq_appendices_ratio", "instrument_settings_acq_xsize", "instrument_settings_acq_ysize", "instrument_settings_acq_erase_border", "instrument_settings_acq_threshold", "instrument_settings_process_datetime", "instrument_settings_process_gamma", "instrument_settings_images_post_process", "instrument_settings_aa", "instrument_settings_exp", "instrument_settings_image_volume_l", "instrument_settings_pixel_size_mm", "instrument_settings_depth_offset_m", "instrument_settings_particle_minimum_size_pixels", "instrument_settings_vignettes_minimum_size_pixels", "instrument_settings_particle_minimum_size_esd", "instrument_settings_vignettes_minimum_size_esd", "instrument_settings_acq_shutter", "instrument_settings_acq_shutter_speed", "instrument_settings_acq_exposure", "visual_qc_validator_user_id", "sample_type_id", "project_id", "visual_qc_status_id"] - // // Can be sort_by - // const sort_param_restricted = ["sample_id", "root_folder_path", "sample_title", "sample_acronym", "sample_description", "sample_information", "cruise", "ship", "data_owner_name", "data_owner_email", "operator_name", "operator_email", "chief_scientist_name", "chief_scientist_email", "override_depth_offset", "enable_descent_filter", "privacy_duration", "visible_duration", "public_duration", "instrument_model", "serial_number", "sample_creation_date"] + // Can be sort_by + const sort_param_restricted = ["sample_id", "sample_name", "comment", "instrument_serial_number", "optional_structure_id", "max_pressure", "integration_time", "station_id", "sampling_date", "latitude", "longitude", "wind_direction", "wind_speed", "sea_state", "nebulousness", "bottom_depth", "operator_email", "filename", "filter_first_image", "filter_last_image", "instrument_settings_acq_gain", "instrument_settings_acq_description", "instrument_settings_acq_task_type", "instrument_settings_acq_choice", "instrument_settings_acq_disk_type", "instrument_settings_acq_appendices_ratio", "instrument_settings_acq_xsize", "instrument_settings_acq_ysize", "instrument_settings_acq_erase_border", "instrument_settings_acq_threshold", "instrument_settings_process_datetime", "instrument_settings_process_gamma", "instrument_settings_images_post_process", "instrument_settings_aa", "instrument_settings_exp", "instrument_settings_image_volume_l", "instrument_settings_pixel_size_mm", "instrument_settings_depth_offset_m", "instrument_settings_particle_minimum_size_pixels", "instrument_settings_vignettes_minimum_size_pixels", "instrument_settings_particle_minimum_size_esd", "instrument_settings_vignettes_minimum_size_esd", "instrument_settings_acq_shutter", "instrument_settings_acq_shutter_speed", "instrument_settings_acq_exposure", "visual_qc_validator_user_id", "sample_type_id", "project_id", "visual_qc_status_id"] - // return await this.getSamples(options, filter_params_restricted, sort_param_restricted, this.order_by_allow_params, this.filter_operator_allow_params) - // } + return await this.getSamples(options, filter_params_restricted, sort_param_restricted, this.order_by_allow_params, this.filter_operator_allow_params) + } - // //TODO MOVE TO SEARCH REPOSITORY - // private async getSamples(options: PreparedSearchOptions, filtering_params: string[], sort_by_params: string[], order_by_params: string[], filter_operator_params: string[]): Promise> { - // const unauthorizedParams: string[] = []; - // //TODO move to a search repository - // // Filter options.sort_by by sorting params - // options.sort_by = options.sort_by.filter(sort_by => { - // let is_valid = true; - // if (!sort_by_params.includes(sort_by.sort_by)) { - // unauthorizedParams.push(`Unauthorized sort_by: ${sort_by.sort_by}`); - // is_valid = false; - // } - // if (!order_by_params.includes(sort_by.order_by)) { - // unauthorizedParams.push(`Unauthorized order_by: ${sort_by.order_by}`); - // is_valid = false; - // } - // return is_valid; - // }); + private async getSamples(options: PreparedSearchOptions, filtering_params: string[], sort_by_params: string[], order_by_params: string[], filter_operator_params: string[]): Promise> { + const unauthorizedParams: string[] = []; + //TODO move to a search repository + // Filter options.sort_by by sorting params + options.sort_by = options.sort_by.filter(sort_by => { + let is_valid = true; + if (!sort_by_params.includes(sort_by.sort_by)) { + unauthorizedParams.push(`Unauthorized sort_by: ${sort_by.sort_by}`); + is_valid = false; + } + if (!order_by_params.includes(sort_by.order_by)) { + unauthorizedParams.push(`Unauthorized order_by: ${sort_by.order_by}`); + is_valid = false; + } + return is_valid; + }); + + //TODO move to a search repository + // Filter options.filters by filtering params + options.filter = options.filter.filter(filter => { + let is_valid = true; + if (!filtering_params.includes(filter.field)) { + unauthorizedParams.push(`Filter field: ${filter.field}`); + is_valid = false; + } + if (!filter_operator_params.includes(filter.operator)) { + unauthorizedParams.push(`Filter operator: ${filter.operator}`); + is_valid = false; + } + return is_valid; + }); - // //TODO move to a search repository - // // Filter options.filters by filtering params - // options.filter = options.filter.filter(filter => { - // let is_valid = true; - // if (!filtering_params.includes(filter.field)) { - // unauthorizedParams.push(`Filter field: ${filter.field}`); - // is_valid = false; - // } - // if (!filter_operator_params.includes(filter.operator)) { - // unauthorizedParams.push(`Filter operator: ${filter.operator}`); - // is_valid = false; - // } - // return is_valid; - // }); + //TODO move to a search repository + if (unauthorizedParams.length > 0) { + throw new Error(`Unauthorized or unexisting parameters : ${unauthorizedParams.join(', ')}`); + } - // //TODO move to a search repository - // if (unauthorizedParams.length > 0) { - // throw new Error(`Unauthorized or unexisting parameters : ${unauthorizedParams.join(', ')}`); - // } + return await this.sampleDataSource.getAll(options); + } - // return await this.sampleDataSource.getAll(options); - // } + // getSampleType + async getSampleType(sample_type: SampleTypeRequestModel): Promise { + + // Clean the sample type to keep only sample_type_id, sample_type_label, sample_type_description if they are defined + const cleaned_sample_type: SampleTypeRequestModel = {}; + + if (sample_type.sample_type_id !== undefined) cleaned_sample_type.sample_type_id = sample_type.sample_type_id; + if (sample_type.sample_type_label !== undefined) cleaned_sample_type.sample_type_label = sample_type.sample_type_label; + if (sample_type.sample_type_description !== undefined) cleaned_sample_type.sample_type_description = sample_type.sample_type_description; + // Get the result from the data source + const result = await this.sampleDataSource.getSampleType(cleaned_sample_type); + return result; + } + // getVisualQCStatus + async getVisualQCStatus(visual_qc_status: VisualQualityCheckStatusRequestModel): Promise { + // Clean the visual qc status to keep only visual_qc_status_id, visual_qc_status_label, if they are defined + const cleaned_visual_qc_status: VisualQualityCheckStatusRequestModel = {}; + + if (visual_qc_status.visual_qc_status_id !== undefined) cleaned_visual_qc_status.visual_qc_status_id = visual_qc_status.visual_qc_status_id; + if (visual_qc_status.visual_qc_status_label !== undefined) cleaned_visual_qc_status.visual_qc_status_label = visual_qc_status.visual_qc_status_label; + + // Get the result from the data source + const result = await this.sampleDataSource.getVisualQCStatus(cleaned_visual_qc_status); + return result; + } } \ No newline at end of file diff --git a/src/domain/repositories/task-repository.ts b/src/domain/repositories/task-repository.ts index 1243a84..a214c43 100644 --- a/src/domain/repositories/task-repository.ts +++ b/src/domain/repositories/task-repository.ts @@ -320,7 +320,6 @@ export class TaskRepositoryImpl implements TaskRepository { } // Get One Task async getOneTask(task: PrivateTaskRequestModel): Promise { - console.log("getOneTask", task) return await this.taskDataSource.getOne(task); } async getTasksByUser(user: UserRequestModel): Promise { @@ -358,7 +357,7 @@ export class TaskRepositoryImpl implements TaskRepository { await this.taskDataSource.updateOne({ task_id: task_id, task_error: error.message }) // Update the task status to error - this.statusManager({ + await this.statusManager({ task_id: task_id }, TasksStatus.Error) diff --git a/src/domain/use-cases/sample/delete-sample.ts b/src/domain/use-cases/sample/delete-sample.ts new file mode 100644 index 0000000..e92fb87 --- /dev/null +++ b/src/domain/use-cases/sample/delete-sample.ts @@ -0,0 +1,79 @@ +import { PublicSampleModel } from "../../entities/sample"; +import { UserUpdateModel } from "../../entities/user"; +import { PrivilegeRepository } from "../../interfaces/repositories/privilege-repository"; +import { SampleRepository } from "../../interfaces/repositories/sample-repository"; +import { UserRepository } from "../../interfaces/repositories/user-repository"; +import { DeleteSampleUseCase } from "../../interfaces/use-cases/sample/delete-sample"; + +export class DeleteSample implements DeleteSampleUseCase { + userRepository: UserRepository + sampleRepository: SampleRepository + privilegeRepository: PrivilegeRepository + + constructor(userRepository: UserRepository, sampleRepository: SampleRepository, privilegeRepository: PrivilegeRepository) { + this.userRepository = userRepository + this.sampleRepository = sampleRepository + this.privilegeRepository = privilegeRepository + } + + async execute(current_user: UserUpdateModel, sample_id_to_delete: number, project_id?: number): Promise { + // Ensure the user is valid and can be used + await this.userRepository.ensureUserCanBeUsed(current_user.user_id); + + // Ensure the sample to delete exists + const sample = await this.ensureSampleExists(sample_id_to_delete); + + // Ensure the project_id is valid if specified + if (project_id && sample.project_id != project_id) { + throw new Error("The given project_id does not match the sample's project_id"); + } + + // Ensure the current user has permission to delete the sample + await this.ensureUserCanDelete(current_user, sample.project_id); + + // Delete the sample + await this.deleteSample(sample, sample.project_id); + } + + // Ensure the sample to delete exists + private async ensureSampleExists(sample_id: number): Promise { + const result = await this.sampleRepository.getSample({ sample_id: sample_id }); + if (result === null) { + throw new Error("Cannot find sample to delete"); + } + return result; + } + + // Ensure user is admin or has manager privilege to delete the sample + private async ensureUserCanDelete(current_user: UserUpdateModel, project_id: number): Promise { + const userIsAdmin = await this.userRepository.isAdmin(current_user.user_id); + const userHasPrivilege = await this.privilegeRepository.isManager({ + user_id: current_user.user_id, + project_id: project_id + }); + + if (!userIsAdmin && !userHasPrivilege) { + throw new Error("Logged user cannot delete this sample"); + } + } + + // Delete the sample + private async deleteSample(sample: PublicSampleModel, project_id: number): Promise { + await this.deleteSampleFromDatabase(sample.sample_id); + await this.deleteSampleFromStorage(sample.sample_name, project_id); + } + + private async deleteSampleFromDatabase(sample_id: number): Promise { + const deletedSamplesCount = await this.sampleRepository.deleteSample({ sample_id: sample_id }); + if (deletedSamplesCount === 0) { + throw new Error("Cannot delete sample"); + } + } + + private async deleteSampleFromStorage(sample_name: string, project_id: number): Promise { + const deletedSamplesCount = await this.sampleRepository.deleteSampleFromStorage(sample_name, project_id); + if (deletedSamplesCount === 0) { + throw new Error("Cannot delete sample"); + } + } +} \ No newline at end of file diff --git a/src/domain/use-cases/sample/import-samples.ts b/src/domain/use-cases/sample/import-samples.ts index 7e9afee..afeacbe 100644 --- a/src/domain/use-cases/sample/import-samples.ts +++ b/src/domain/use-cases/sample/import-samples.ts @@ -1,5 +1,5 @@ -import { PublicSampleResponseModel } from "../../entities/sample"; +import { PublicHeaderSampleResponseModel, SampleRequestCreationModel } from "../../entities/sample"; import { UserUpdateModel } from "../../entities/user"; import { PrivilegeRepository } from "../../interfaces/repositories/privilege-repository"; import { SampleRepository } from "../../interfaces/repositories/sample-repository"; @@ -38,11 +38,6 @@ export class ImportSamples implements ImportSamplesUseCase { const project: ProjectResponseModel = await this.getProjectIfExist(project_id); - const samples = await this.listImportableSamples(project); - - // Check that asked samples are in the importable list of samples - this.ensureSamplesAreImportables(samples, samples_names_to_import); - // create a task to import samples const task_id = await this.createImportSamplesTask(current_user, project, samples_names_to_import); @@ -53,31 +48,12 @@ export class ImportSamples implements ImportSamplesUseCase { } // start the task - this.startImportTask(task, samples_names_to_import, project.instrument_model, project); + this.startImportTask(task, samples_names_to_import, project.instrument_model, project, current_user); return task; } - ensureSamplesAreImportables(samples: PublicSampleResponseModel[], samples_names_to_import: string[]) { - this.ensureSamplesAreBothInHeadersAndInRawData(samples, samples_names_to_import); - this.ensureSamplesAreNotAlreadyImported(samples_names_to_import); - } - - ensureSamplesAreBothInHeadersAndInRawData(samples: PublicSampleResponseModel[], samples_names_to_import: string[]) { - const samples_names_set = new Set(samples.map(sample => sample.sample_name)); - - const missing_samples = samples_names_to_import.filter(sample_id => !samples_names_set.has(sample_id)); - - if (missing_samples.length > 0) { - throw new Error("Samples not importable: " + missing_samples.join(", ")); - } - } - - ensureSamplesAreNotAlreadyImported(samples_names_to_import: string[]) { - //TODO - console.log("TODO: ensureSamplesAreNotAlreadyImported please do an import update", samples_names_to_import); - } - createImportSamplesTask(current_user: UserUpdateModel, project: ProjectResponseModel, samples: string[]) { + async createImportSamplesTask(current_user: UserUpdateModel, project: ProjectResponseModel, samples: string[]): Promise { const task: PublicTaskRequestCreationModel = { task_type: TaskType.Import, task_status: TasksStatus.Pending, @@ -85,13 +61,13 @@ export class ImportSamples implements ImportSamplesUseCase { task_project_id: project.project_id, task_params: { samples: samples } } - return this.taskRepository.createTask(task); - + return await this.taskRepository.createTask(task); } - private async listImportableSamples(project: ProjectResponseModel): Promise { + private async listImportableSamples(project: ProjectResponseModel): Promise { await this.sampleRepository.ensureFolderExists(project.root_folder_path); - const samples = await this.sampleRepository.listImportableSamples(project.root_folder_path, project.instrument_model); + const dest_folder = path.join(this.DATA_STORAGE_FS_STORAGE, `${project.project_id}`); + const samples = await this.sampleRepository.listImportableSamples(project.root_folder_path, project.instrument_model, dest_folder, project.project_id); // Ensure the task to get exists if (!samples) { throw new Error("No samples to import"); } @@ -117,32 +93,58 @@ export class ImportSamples implements ImportSamplesUseCase { } } - private async startImportTask(task: TaskResponseModel, samples_names_to_import: string[], instrument_model: string, project: ProjectResponseModel) { + private async startImportTask(task: TaskResponseModel, samples_names_to_import: string[], instrument_model: string, project: ProjectResponseModel, current_user: UserUpdateModel) { const task_id = task.task_id; try { await this.taskRepository.startTask({ task_id: task_id }); // 1/4 Do validation before importing - //TODO LATER + const importable_samples = await this.listImportableSamples(project); + // Check that asked samples are in the importable list of samples + await this.ensureSamplesAreImportables(importable_samples, samples_names_to_import, task_id); // 2/4 Copy source files to hiden project folder await this.copySourcesToProjectFolder(task_id, samples_names_to_import, instrument_model, project); - - // 3/4 Import samples - //await this.sampleRepository.importSamples(task_id, project.project_id, samples_names_to_import); - - // 4/4 generate qc report by samples + } catch (error) { + await this.taskRepository.failedTask(task_id, error); + return; + } + try { + // 3/4 generate qc report by samples //TODO LATER but we can already et the qc flag to un validated + // 4/4 Create samples + await this.importSamples(task_id, project, current_user.user_id, samples_names_to_import); + // finish task await this.taskRepository.finishTask({ task_id: task_id }); } catch (error) { + await this.deleteSourcesFromProjectFolder(task_id, samples_names_to_import, project); this.taskRepository.failedTask(task_id, error); } + } + async ensureSamplesAreImportables(samples: PublicHeaderSampleResponseModel[], samples_names_to_import: string[], task_id: number) { + await this.taskRepository.updateTaskProgress({ task_id: task_id }, 10, "Step 1/4 sample validation : start"); + this.ensureSamplesAreBothInHeadersAndInRawData(samples, samples_names_to_import); + //TODO LATER add more validation + await this.taskRepository.updateTaskProgress({ task_id: task_id }, 20, "Step 1/4 sample validation : done"); } + ensureSamplesAreBothInHeadersAndInRawData(samples: PublicHeaderSampleResponseModel[], samples_names_to_import: string[]) { + const samples_names_set = new Set(samples.map(sample => sample.sample_name)); + + const missing_samples = samples_names_to_import.filter(sample_id => !samples_names_set.has(sample_id)); + + if (missing_samples.length > 0) { + throw new Error("Samples not importable: " + missing_samples.join(", ")); + } + } + + async copySourcesToProjectFolder(task_id: number, samples_names_to_import: string[], instrument_model: string, project: ProjectResponseModel) { + await this.taskRepository.updateTaskProgress({ task_id: task_id }, 25, "Step 2/4 sample folders copy : start"); + const dest_folder = path.join(this.DATA_STORAGE_FS_STORAGE, `${project.project_id}`); const root_folder_path = project.root_folder_path; let source_folder; @@ -155,11 +157,46 @@ export class ImportSamples implements ImportSamplesUseCase { throw new Error("Unknown instrument model"); } await this.sampleRepository.copySamplesToImportFolder(source_folder, dest_folder, samples_names_to_import); - await this.taskRepository.updateTaskProgress({ task_id: task_id }, 50, "Step 2/4 sample folders copied"); + await this.taskRepository.updateTaskProgress({ task_id: task_id }, 50, "Step 2/4 sample folders copy : done"); + + } + async deleteSourcesFromProjectFolder(task_id: number, samples_names_to_import: string[], project: ProjectResponseModel) { + // Delete sources files from project folder + const dest_folder = path.join(this.DATA_STORAGE_FS_STORAGE, `${project.project_id}`); + await this.sampleRepository.deleteSamplesFromImportFolder(dest_folder, samples_names_to_import); + // Log the action + const task_file_path = await this.taskRepository.getTask({ task_id }); + if (!task_file_path) { + throw new Error("Cannot find task"); + } + await this.taskRepository.logMessage(task_file_path.task_log_file_path, "Samples import failed, sources files deleted for samples: " + samples_names_to_import.join(", ")); } - // async importSamples(task_id: number, project_id: number, samples_names_to_import: string[]) { + async importSamples(task_id: number, project: ProjectResponseModel, current_user_id: number, samples_names_to_import: string[]): Promise { + await this.taskRepository.updateTaskProgress({ task_id: task_id }, 75, "Step 4/4 samples creation : start"); - // } + // Common sample data + const base_sample: Partial = { + project_id: project.project_id, + visual_qc_validator_user_id: current_user_id + } + // Format samples to import + const formated_samples: SampleRequestCreationModel[] = await Promise.all( + samples_names_to_import.map(async (sample_name) => { + const sample = await this.sampleRepository.formatSampleToImport( + { ...base_sample, sample_name }, + project.instrument_model + ); + return sample; + }) + ); + + + // Create samples + const created_samples_ids = await this.sampleRepository.createManySamples(formated_samples); + + await this.taskRepository.updateTaskProgress({ task_id: task_id }, 100, "Step 4/4 samples creation done"); + return created_samples_ids; + } } \ No newline at end of file diff --git a/src/domain/use-cases/sample/list-importable-samples.ts b/src/domain/use-cases/sample/list-importable-samples.ts index 58e9fe8..f5f26ee 100644 --- a/src/domain/use-cases/sample/list-importable-samples.ts +++ b/src/domain/use-cases/sample/list-importable-samples.ts @@ -1,4 +1,4 @@ -import { PublicSampleResponseModel } from "../../entities/sample"; +import { PublicHeaderSampleResponseModel } from "../../entities/sample"; import { UserUpdateModel } from "../../entities/user"; import { PrivilegeRepository } from "../../interfaces/repositories/privilege-repository"; import { SampleRepository } from "../../interfaces/repositories/sample-repository"; @@ -7,21 +7,24 @@ import { UserRepository } from "../../interfaces/repositories/user-repository"; import { ListImportableSamplesUseCase } from "../../interfaces/use-cases/sample/list-importable-samples"; import { ProjectResponseModel } from "../../entities/project"; +import path from "path"; export class ListImportableSamples implements ListImportableSamplesUseCase { sampleRepository: SampleRepository userRepository: UserRepository privilegeRepository: PrivilegeRepository projectRepository: ProjectRepository + DATA_STORAGE_FS_STORAGE: string - constructor(sampleRepository: SampleRepository, userRepository: UserRepository, privilegeRepository: PrivilegeRepository, projectRepository: ProjectRepository) { + constructor(sampleRepository: SampleRepository, userRepository: UserRepository, privilegeRepository: PrivilegeRepository, projectRepository: ProjectRepository, DATA_STORAGE_FS_STORAGE: string) { this.sampleRepository = sampleRepository this.userRepository = userRepository this.privilegeRepository = privilegeRepository this.projectRepository = projectRepository + this.DATA_STORAGE_FS_STORAGE = DATA_STORAGE_FS_STORAGE } - async execute(current_user: UserUpdateModel, project_id: number): Promise { + async execute(current_user: UserUpdateModel, project_id: number): Promise { // Ensure the user is valid and can be used await this.userRepository.ensureUserCanBeUsed(current_user.user_id); @@ -38,9 +41,10 @@ export class ListImportableSamples implements ListImportableSamplesUseCase { return samples; } - private async listImportableSamples(project: ProjectResponseModel): Promise { + private async listImportableSamples(project: ProjectResponseModel): Promise { + const dest_folder = path.join(this.DATA_STORAGE_FS_STORAGE, `${project.project_id}`); await this.sampleRepository.ensureFolderExists(project.root_folder_path); - const samples = await this.sampleRepository.listImportableSamples(project.root_folder_path, project.instrument_model); + const samples = await this.sampleRepository.listImportableSamples(project.root_folder_path, project.instrument_model, dest_folder, project.project_id); return samples; } diff --git a/src/domain/use-cases/sample/search-samples.ts b/src/domain/use-cases/sample/search-samples.ts new file mode 100644 index 0000000..6f18a06 --- /dev/null +++ b/src/domain/use-cases/sample/search-samples.ts @@ -0,0 +1,108 @@ +import { FilterSearchOptions, PreparedSearchOptions, SearchInfo, SearchOptions, SearchResult } from "../../entities/search"; +import { UserUpdateModel } from "../../entities/user"; +import { UserRepository } from "../../interfaces/repositories/user-repository"; +import { SearchRepository } from "../../interfaces/repositories/search-repository"; +import { SampleRepository } from "../../interfaces/repositories/sample-repository"; +import { InstrumentModelRepository } from "../../interfaces/repositories/instrument_model-repository"; +import { PrivilegeRepository } from "../../interfaces/repositories/privilege-repository"; +import { SearchSamplesUseCase } from "../../interfaces/use-cases/sample/search-samples"; +import { PublicSampleModel } from "../../entities/sample"; + +export class SearchSamples implements SearchSamplesUseCase { + userRepository: UserRepository + sampleRepository: SampleRepository + searchRepository: SearchRepository + instrumentModelRepository: InstrumentModelRepository + privilegeRepository: PrivilegeRepository + + constructor(userRepository: UserRepository, sampleRepository: SampleRepository, searchRepository: SearchRepository, instrumentModelRepository: InstrumentModelRepository, privilegeRepository: PrivilegeRepository) { + this.userRepository = userRepository + this.sampleRepository = sampleRepository + this.searchRepository = searchRepository + this.instrumentModelRepository = instrumentModelRepository + this.privilegeRepository = privilegeRepository + } + async execute(current_user: UserUpdateModel, options: SearchOptions, filters: FilterSearchOptions[], project_id?: number): Promise<{ samples: PublicSampleModel[], search_info: SearchInfo }> { + + // Ensure the current user is valid and not deleted + await this.userRepository.ensureUserCanBeUsed(current_user.user_id); + + // Prepare search options + let prepared_options: PreparedSearchOptions = this.prepareSearchOptions(options, filters); + + // Apply additional filters + prepared_options = await this.applyAdditionalFilters(current_user, prepared_options, project_id); + + // Fetch samples based on prepared search options + const result: SearchResult = await this.sampleRepository.standardGetSamples(prepared_options); + const samples = result.items; + + // Format search info + const search_info: SearchInfo = this.searchRepository.formatSearchInfo(result, prepared_options); + + return { search_info, samples }; + } + + + // Prepares the search options by formatting filters and sort options. + private prepareSearchOptions(options: SearchOptions, filters: FilterSearchOptions[]): PreparedSearchOptions { + options.filter = filters && filters.length > 0 ? this.searchRepository.formatFilters(filters) : []; + if (options.sort_by) options.sort_by = this.searchRepository.formatSortBy(options.sort_by as string); + return options as PreparedSearchOptions; + } + + // Applies additional filters based on specific conditions. + private async applyAdditionalFilters(current_user: UserUpdateModel, options: PreparedSearchOptions, project_id?: number): Promise { + if (options.filter.length > 0) { + options = await this.applySampleTypeFilter(options); + options = await this.applyVisualQCStatusFilter(options); + } + if (project_id && typeof project_id === "number") { + options.filter.push({ field: "project_id", operator: "=", value: project_id }); + } + return options; + } + + // Applies the sample type filter. + private async applySampleTypeFilter(options: PreparedSearchOptions): Promise { + const sampleTypeModelFilter = options.filter.find(f => f.field === "sample_type_label"); + if (sampleTypeModelFilter) { + // Delete the sample type label filter + options.filter = options.filter.filter(f => f.field !== "sample_type_label"); + + // If filter sample type label value is a string, replace it with the sample type id + if (typeof sampleTypeModelFilter.value == "string") { + const sampleTypeModel = await this.sampleRepository.getSampleType({ sample_type_label: sampleTypeModelFilter.value }); + // If sample type not found, throw an error + if (!sampleTypeModel) { + throw new Error("Sample type not found"); + } + // Replace the sample type label filter with the sample type id filter + sampleTypeModelFilter.field = "sample_type_id"; + sampleTypeModelFilter.value = sampleTypeModel.sample_type_id; + // Add the new filter to the options + options.filter.push(sampleTypeModelFilter); + } + } + return options; + } + // Applies the visual QC status filter. + private async applyVisualQCStatusFilter(options: PreparedSearchOptions): Promise { + const visualQCStatusFilter = options.filter.find(f => f.field === "visual_qc_status_label"); + if (visualQCStatusFilter) { + // If filter visual QC status label value is a string, replace it with the visual QC status id + if (typeof visualQCStatusFilter.value == "string") { + const visualQCStatusModel = await this.sampleRepository.getVisualQCStatus({ visual_qc_status_label: visualQCStatusFilter.value }); + if (!visualQCStatusModel) { + throw new Error("Visual QC status not found"); + } + visualQCStatusFilter.field = "visual_qc_status_id"; + visualQCStatusFilter.value = visualQCStatusModel.visual_qc_status_id; + options.filter = options.filter.filter(f => f.field !== "visual_qc_status_label"); + options.filter.push(visualQCStatusFilter); + } + } + return options; + } + +} diff --git a/src/main.ts b/src/main.ts index 1124843..495d9d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { MiddlewareAuthCookie } from './presentation/middleware/auth-cookie' import { MiddlewareAuthValidation } from './presentation/middleware/auth-validation' import { MiddlewareUserValidation } from './presentation/middleware/user-validation' import { MiddlewareProjectValidation } from './presentation/middleware/project-validation' +import { MiddlewareSampleValidation } from './presentation/middleware/sample-validation' import UserRouter from './presentation/routers/user-router' import AuthRouter from './presentation/routers/auth-router' @@ -32,6 +33,8 @@ import { DeleteTask } from './domain/use-cases/task/delete-task' import { SearchTask } from './domain/use-cases/task/search-tasks' import { GetOneTask } from './domain/use-cases/task/get-one-task' import { GetLogFileTask } from './domain/use-cases/task/get-log-file-task' +import { DeleteSample } from './domain/use-cases/sample/delete-sample' +import { SearchSamples } from './domain/use-cases/sample/search-samples' import { UserRepositoryImpl } from './domain/repositories/user-repository' import { AuthRepositoryImpl } from './domain/repositories/auth-repository' @@ -48,6 +51,7 @@ import { SQLiteInstrumentModelDataSource } from './data/data-sources/sqlite/sqli import { SQLiteProjectDataSource } from './data/data-sources/sqlite/sqlite-project-data-source' import { SQLitePrivilegeDataSource } from './data/data-sources/sqlite/sqlite-privilege-data-source' import { SQLiteTaskDataSource } from './data/data-sources/sqlite/sqlite-task-data-source' +import { SQLiteSampleDataSource } from './data/data-sources/sqlite/sqlite-sample-data-source' import sqlite3 from 'sqlite3' import { BcryptAdapter } from './infra/cryptography/bcript' @@ -118,6 +122,7 @@ async function getSQLiteDS() { const project_dataSource = new SQLiteProjectDataSource(db) const privilege_dataSource = new SQLitePrivilegeDataSource(db) const task_datasource = new SQLiteTaskDataSource(db) + const sample_dataSource = new SQLiteSampleDataSource(db) const transporter = await mailerAdapter.createTransport({ host: config.MAIL_HOST, @@ -135,7 +140,7 @@ async function getSQLiteDS() { const instrument_model_repo = new InstrumentModelRepositoryImpl(instrument_model_dataSource) const project_repo = new ProjectRepositoryImpl(project_dataSource) const privilege_repo = new PrivilegeRepositoryImpl(privilege_dataSource) - const sample_repo = new SampleRepositoryImpl() + const sample_repo = new SampleRepositoryImpl(sample_dataSource, config.DATA_STORAGE_FS_STORAGE) const task_repo = new TaskRepositoryImpl(task_datasource, fsAdapter, config.DATA_STORAGE_FOLDER) const userMiddleWare = @@ -164,12 +169,15 @@ async function getSQLiteDS() { const projectMiddleWare = ProjectRouter( new MiddlewareAuthCookie(jwtAdapter, config.ACCESS_TOKEN_SECRET, config.REFRESH_TOKEN_SECRET), new MiddlewareProjectValidation(), + new MiddlewareSampleValidation(), new CreateProject(user_repo, project_repo, instrument_model_repo, privilege_repo), new DeleteProject(user_repo, project_repo, privilege_repo), new UpdateProject(user_repo, project_repo, instrument_model_repo, privilege_repo), new SearchProject(user_repo, project_repo, search_repo, instrument_model_repo, privilege_repo), - new ListImportableSamples(sample_repo, user_repo, privilege_repo, project_repo), - new ImportSamples(sample_repo, user_repo, privilege_repo, project_repo, task_repo, config.DATA_STORAGE_FS_STORAGE) + new ListImportableSamples(sample_repo, user_repo, privilege_repo, project_repo, config.DATA_STORAGE_FS_STORAGE), + new ImportSamples(sample_repo, user_repo, privilege_repo, project_repo, task_repo, config.DATA_STORAGE_FS_STORAGE), + new DeleteSample(user_repo, sample_repo, privilege_repo), + new SearchSamples(user_repo, sample_repo, search_repo, instrument_model_repo, privilege_repo), ) const taskMiddleWare = TaskRouter( diff --git a/src/presentation/interfaces/middleware/sample-validation.ts b/src/presentation/interfaces/middleware/sample-validation.ts new file mode 100644 index 0000000..9a4e236 --- /dev/null +++ b/src/presentation/interfaces/middleware/sample-validation.ts @@ -0,0 +1,11 @@ +// TODO IMiddlewareSampleValidation +import { NextFunction, Request, Response } from "express" +import { ValidationChain } from "express-validator" + +export interface IMiddlewareSampleValidation { + rulesGetSamples: (ValidationChain | ((req: Request, res: Response, next: NextFunction) => Response | undefined))[] + // rulesSampleRequestCreationModel: (ValidationChain | ((req: Request, res: Response, next: NextFunction) => Response | undefined))[] + // rulesSampleUpdateModel: ((Middleware & ContextRunner) | ((req: Request, res: Response, next: NextFunction) => Response | undefined))[] +} + + diff --git a/src/presentation/middleware/sample-validation.ts b/src/presentation/middleware/sample-validation.ts new file mode 100644 index 0000000..2512236 --- /dev/null +++ b/src/presentation/middleware/sample-validation.ts @@ -0,0 +1,294 @@ +import { NextFunction, Request, Response } from 'express'; +import { validationResult, query } from 'express-validator'; +//check +import { IMiddlewareSampleValidation } from '../interfaces/middleware/sample-validation'; + + +export class MiddlewareSampleValidation implements IMiddlewareSampleValidation { + // rulesSampleRequestCreationModel = [ + // // Root Folder Path Validation + // check('root_folder_path') + // .trim() + // .not().isEmpty().withMessage('Root Folder Path is required.'), + + // // Sample Title Validation + // check('sample_title') + // .trim() + // .not().isEmpty().withMessage('Sample title is required.'), + + // // Sample acrony Validation + // check('sample_acronym') + // .trim() + // .not().isEmpty().withMessage('Sample acronym is required.'), + + // // Sample description Validation + // check('sample_description') + // .trim() + // .not().isEmpty().withMessage('Sample description is required.'), + + // // Sample information Validation + // check('sample_information') + // .trim() + // .not().isEmpty().withMessage('Sample information is required.'), + + // // Sample cruise Validation + // check('cruise') + // .trim() + // .not().isEmpty().withMessage('Cruise name is required.'), + + // // Sample ship Validation + // check('ship').trim() + // .not().isEmpty().withMessage('Ship name is required.'), + + // // Data owner name Validation + // check('data_owner_name') + // .trim() + // .not().isEmpty().withMessage('Data owner name is required.'), + + // // Data owner email Validation + // check('data_owner_email').exists().withMessage('Data owner email is required.') + // .trim() + // .normalizeEmail() + // .isEmail().withMessage('Data owner email must be a valid email address.') + // .bail(), + + // // Operator name Validation + // check('operator_name') + // .trim() + // .not().isEmpty().withMessage('Operator name is required.'), + + // // Operator email Validation + // check('operator_email').exists().withMessage('Operator email is required.') + // .trim() + // .normalizeEmail() + // .isEmail().withMessage('Operator email must be a valid email address.') + // .bail(), + + // // Chief scientist name Validation + // check('chief_scientist_name') + // .trim() + // .not().isEmpty().withMessage('Chief scientist name is required.'), + + // // Chief scientist email Validation + // check('chief_scientist_email').exists().withMessage('Chief scientist email is required.') + // .trim() + // .normalizeEmail() + // .isEmail().withMessage('Chief scientist email must be a valid email address.') + // .bail(), + + // // Override depth offset Validation + // check('override_depth_offset').optional() + // .isFloat().withMessage('Override depth offset must be a float value.'), + + // // Enable descent filter Validation + // check('enable_descent_filter') + // .exists().withMessage('Enable descent filter is required.') + // .isBoolean().withMessage('Enable descent filter must be a boolean value.'), + + // // Privacy duration Validation + // check('privacy_duration').default(2) + // .isInt({ min: 0 }).withMessage('Privacy duration must be a number and must be greater than 0.'), + + // // Visible duration Validation + // check('visible_duration').default(24) + // .isInt({ min: 0 }).withMessage('Visible duration must be a number and must be greater than 0.'), + + // // Public duration Validation + // check('public_duration').default(36) + // .isInt({ min: 0 }).withMessage('Public duration must be a number and must be greater than 0.'), + + // // Instrument Validation + // check('instrument_model') + // .exists().withMessage('Instrument model is required.') + // .isString() + // .isIn(['UVP5HD', 'UVP5SD', 'UVP5Z', 'UVP6LP', 'UVP6HF', 'UVP6MHP', 'UVP6MHF']) + // .withMessage("Instrument model must be a string included in the following list of instrument models: ['UVP5HD', 'UVP5SD', 'UVP5Z', 'UVP6LP', 'UVP6HF', 'UVP6MHP', 'UVP6MHF']"), + // // Serial number Validation + // check('serial_number').optional() + // .trim() + // .not().isEmpty().withMessage('Serial number is required.'), + // // Sample contact Validation + // check('contact') + // .exists().withMessage('Contact is required.') + // .isObject().withMessage('Contact must be an object.') + // .bail() + // .custom((value: any) => { + // if (value.user_id === undefined) { + // throw new Error('Contact user_id is required.'); + // } + // return true; + // }), + // // Sample members Validation + // check('members') + // .exists().withMessage('Members are required.') + // .isArray().withMessage('Members must be an array.') + // .bail() + // .custom((value: any) => { + // if (value.length > 0) { + // for (const member of value) { + // if (member.user_id === undefined) { + // throw new Error('Member user_id is required.'); + // } + // } + // } + // return true; + // }), + // // Sample managers Validation + // check('managers') + // .exists().withMessage('Managers are required.') + // .isArray().withMessage('Managers must be an array.') + // .bail() + // .custom((value: any) => { + // if (value.length === 0) { + // throw new Error('At least one user must be a manager'); + // } + // for (const manager of value) { + // if (manager.user_id === undefined) { + // throw new Error('Manager user_id is required.'); + // } + // } + // return true; + // }), + + + // // Error Handling Middleware + // (req: Request, res: Response, next: NextFunction) => { + // const errors = validationResult(req); + // if (!errors.isEmpty()) { + // // Centralized error handling for validation errors + // return res.status(422).json({ errors: errors.array() }); + // } + // next(); + // }, + // ]; + + // rulesSampleUpdateModel = [ + // // Root Folder Path Validation + // check('root_folder_path').optional() + // .trim() + // .not().isEmpty().withMessage('Root Folder Path value cannot be empty.'), + // // Sample Title Validation + // check('sample_title').optional() + // .trim() + // .not().isEmpty().withMessage('Sample title cannot be empty.'), + // // Sample acrony Validation + // check('sample_acronym').optional() + // .trim() + // .not().isEmpty().withMessage('Sample acronym cannot be empty.'), + // // Sample description Validation + // check('sample_description').optional() + // .trim() + // .not().isEmpty().withMessage('Sample description cannot be empty.'), + // // Sample information Validation + // check('sample_information').optional() + // .trim() + // .not().isEmpty().withMessage('Sample information cannot be empty.'), + + // // Sample cruise Validation + // check('cruise').optional() + // .trim() + // .not().isEmpty().withMessage('Cruise name cannot be empty.'), + + // // Sample ship Validation + // check('ship').optional().trim() + // .not().isEmpty().withMessage('Ship name cannot be empty.'), + + // // Data owner name Validation + // check('data_owner_name').optional() + // .trim() + // .not().isEmpty().withMessage('Data owner name cannot be empty.'), + + // // Data owner email Validation + // check('data_owner_email').optional() + // .trim() + // .normalizeEmail() + // .isEmail().withMessage('Data owner email must be a valid email address.') + // .bail(), + + // // Operator name Validation + // check('operator_name').optional() + // .trim(), + // // Operator email Validation + // check('operator_email').optional() + // .trim() + // .normalizeEmail() + // .isEmail().withMessage('Operator email must be a valid email address.') + // .bail(), + + // // Chief scientist name Validation + // check('chief_scientist_name').optional() + // .trim() + // .not().isEmpty().withMessage('Chief scientist name cannot be empty.'), + + // // Chief scientist email Validation + // check('chief_scientist_email').optional() + // .trim() + // .normalizeEmail() + // .isEmail().withMessage('Chief scientist email must be a valid email address.') + // .bail(), + + // // Override depth offset Validation + // check('override_depth_offset').optional().optional() + // .isFloat().withMessage('Override depth offset must be a float value.'), + + // // Enable descent filter Validation + // check('enable_descent_filter').optional() + // .isBoolean().withMessage('Enable descent filter must be a boolean value.'), + + // // Privacy duration Validation + // check('privacy_duration').optional() + // .isInt({ min: 0 }).withMessage('Privacy duration must be a number and must be greater than 0.'), + + // // Visible duration Validation + // check('visible_duration').optional() + // .isInt({ min: 0 }).withMessage('Visible duration must be a number and must be greater than 0.'), + + // // Public duration Validation + // check('public_duration').optional() + // .isInt({ min: 0 }).withMessage('Public duration must be a number and must be greater than 0.'), + + // // Instrument Validation + // check('instrument_model').optional() + // .isString() + // .isIn(['UVP5HD', 'UVP5SD', 'UVP5Z', 'UVP5Z', 'UVP6LP', 'UVP6HF', 'UVP6MHP', 'UVP6MHF']) + // .withMessage("Instrument model must be a string included in the following list of instrument models: ['UVP5HD', 'UVP5SD', 'UVP5Z', 'UVP6LP', 'UVP6HF', 'UVP6MHP', 'UVP6MHF']"), + + // // Serial number Validation, + // check('serial_number').optional() + // .trim() + // .not().isEmpty().withMessage('Serial number cannot be empty.') + // , + // // Sample Creation Date Validation + // check('user_creation_date') + // .isEmpty().withMessage('Sample creation date cannot be set manually.'), + + // // Error Handling Middleware + // (req: Request, res: Response, next: NextFunction) => { + // const errors = validationResult(req); + // if (!errors.isEmpty()) { + // // Centralized error handling for validation errors + // return res.status(422).json({ errors: errors.array() }); + // } + // next(); + // }, + // ] + + rulesGetSamples = [ + query('page').default(1) + .isInt({ min: 1 }).withMessage('Page must be a number and must be greater than 0.'), + query('limit').default(10) + .isInt({ min: 1 }).withMessage('Limit must be a number and must be greater than 0.'), + query('sort_by').default("asc(sample_id)"), + // DO NOT WORK .isString().withMessage('Sort_by must be a string formatted as follow : desc(field1),asc(field2),desc(field3),...'), + // Error Handling Middleware + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + // Centralized error handling for validation errors + return res.status(422).json({ errors: errors.array() }); + } + next(); + }, + ]; + +} diff --git a/src/presentation/routers/instrument_model-router.ts b/src/presentation/routers/instrument_model-router.ts index a2fc130..dc32c99 100644 --- a/src/presentation/routers/instrument_model-router.ts +++ b/src/presentation/routers/instrument_model-router.ts @@ -26,7 +26,6 @@ export default function InstrumentModelsRouter( }) router.get('/:instrument_model_id/', async (req: Request, res: Response) => { try { - console.log(req.params.instrument_model_id) const instrument_model = await getOneInstrumentModelsUseCase.execute(req.params.instrument_model_id as any); res.status(200).send(instrument_model) } catch (err) { diff --git a/src/presentation/routers/project-router.ts b/src/presentation/routers/project-router.ts index 11e4f09..88020fc 100644 --- a/src/presentation/routers/project-router.ts +++ b/src/presentation/routers/project-router.ts @@ -8,20 +8,26 @@ import { CreateProjectUseCase } from '../../domain/interfaces/use-cases/project/ import { DeleteProjectUseCase } from '../../domain/interfaces/use-cases/project/delete-project' import { UpdateProjectUseCase } from '../../domain/interfaces/use-cases/project/update-project' import { ImportSamplesUseCase } from '../../domain/interfaces/use-cases/sample/import-samples' +import { DeleteSampleUseCase } from '../../domain/interfaces/use-cases/sample/delete-sample' +import { SearchSamplesUseCase } from '../../domain/interfaces/use-cases/sample/search-samples' import { CustomRequest } from '../../domain/entities/auth' import { SearchProjectsUseCase } from '../../domain/interfaces/use-cases/project/search-project' import { ListImportableSamplesUseCase } from '../../domain/interfaces/use-cases/sample/list-importable-samples' +import { IMiddlewareSampleValidation } from '../interfaces/middleware/sample-validation' export default function ProjectRouter( middlewareAuth: MiddlewareAuth, middlewareProjectValidation: IMiddlewareProjectValidation, + middlewareSampleValidation: IMiddlewareSampleValidation, createProjectUseCase: CreateProjectUseCase, deleteProjectUseCase: DeleteProjectUseCase, updateProjectUseCase: UpdateProjectUseCase, searchProjectUseCase: SearchProjectsUseCase, - listImportableSamples: ListImportableSamplesUseCase, - importSamples: ImportSamplesUseCase, + listImportableSamplesUseCase: ListImportableSamplesUseCase, + importSamplesUseCase: ImportSamplesUseCase, + deleteSampleUseCase: DeleteSampleUseCase, + searchSamplesUseCase: SearchSamplesUseCase ) { const router = express.Router() @@ -129,7 +135,7 @@ export default function ProjectRouter( router.get('/:project_id/samples/can_be_imported', middlewareAuth.auth, async (req: Request, res: Response) => { try { - const tasks = await listImportableSamples.execute((req as CustomRequest).token, req.params.project_id as any); + const tasks = await listImportableSamplesUseCase.execute((req as CustomRequest).token, req.params.project_id as any); res.status(200).send(tasks) } catch (err) { console.log(err) @@ -137,24 +143,95 @@ export default function ProjectRouter( // else if (err.message === "Task type label not found") res.status(404).send({ errors: [err.message] }) // else if (err.message === "Task status label not found") res.status(404).send({ errors: [err.message] }) // else res.status(500).send({ errors: ["Cannot search tasks"] }) - res.status(500).send({ errors: ["Cannot search tasks"] }) + res.status(500).send({ errors: ["Cannot list importable samples"] }) } }) - // Pagined and sorted list of filtered task router.post('/:project_id/samples/import', middlewareAuth.auth,/*middlewareSampleValidation.rulesImport,*/ async (req: Request, res: Response) => { try { - const tasks = await importSamples.execute((req as CustomRequest).token, req.params.project_id as any, { ...req.body }.samples); + const tasks = await importSamplesUseCase.execute((req as CustomRequest).token, req.params.project_id as any, { ...req.body }.samples); res.status(200).send(tasks) } catch (err) { - // console.log(err) + console.log(err) // if (err.message === "User cannot be used") res.status(403).send({ errors: [err.message] }) // else if (err.message === "Task type label not found") res.status(404).send({ errors: [err.message] }) // else if (err.message === "Task status label not found") res.status(404).send({ errors: [err.message] }) // else res.status(500).send({ errors: ["Cannot search tasks"] }) - res.status(500).send({ errors: ["Cannot search tasks"] }) + res.status(500).send({ errors: ["Cannot import samples"] }) + } + }) + + // Pagined and sorted list of all project + router.get('/:project_id/samples/', middlewareAuth.auth, async (req: Request, res: Response) => { + try { + const project = await searchSamplesUseCase.execute((req as CustomRequest).token, { ...req.query } as any, [], req.params.project_id as any); + res.status(200).send(project) + } catch (err) { + console.log(err) + if (err.message === "User cannot be used") res.status(403).send({ errors: [err.message] }) + else if (err.message.includes("Unauthorized or unexisting parameters")) res.status(401).send({ errors: [err.message] }) + else if (err.message.includes("Invalid sorting statement")) res.status(401).send({ errors: [err.message] }) + else res.status(500).send({ errors: ["Cannot get samples"] }) } }) + // Pagined and sorted list of filtered samples for the given project + router.post('/:project_id/samples/searches', middlewareAuth.auth, middlewareSampleValidation.rulesGetSamples, async (req: Request, res: Response) => { + try { + const project = await searchSamplesUseCase.execute((req as CustomRequest).token, { ...req.query } as any, req.body as any[], req.params.project_id as any); + res.status(200).send(project) + } catch (err) { + console.log(err) + if (err.message === "User cannot be used") res.status(403).send({ errors: [err.message] }) + else if (err.message === "Instrument model not found") res.status(404).send({ errors: [err.message] }) + else if (err.message.includes("Unauthorized or unexisting parameters")) res.status(401).send({ errors: [err.message] }) + else if (err.message.includes("Invalid sorting statement")) res.status(401).send({ errors: [err.message] }) + else if (err.message.includes("Invalid filter statement ")) res.status(401).send({ errors: [err.message] }) + else res.status(500).send({ errors: ["Cannot search samples"] }) + } + }) + + // Delete a sample + router.delete('/:project_id/samples/:sample_id', middlewareAuth.auth, async (req: Request, res: Response) => { + try { + await deleteSampleUseCase.execute((req as CustomRequest).token, req.params.sample_id as any, req.params.project_id as any); + res.status(200).send({ message: "Sample successfully deleted" }) + } catch (err) { + console.log(err) + if (err.message === "User cannot be used") res.status(403).send({ errors: [err.message] }) + else if (err.message === "Cannot find parent project") res.status(404).send({ errors: [err.message] }) + else if (err.message === "Cannot find sample to delete") res.status(404).send({ errors: [err.message] }) + else if (err.message === "The given project_id does not match the sample's project_id") res.status(401).send({ errors: [err.message] }) + else if (err.message === "Logged user cannot delete sample") res.status(401).send({ errors: [err.message] }) + else res.status(500).send({ errors: ["Cannot delete sample"] }) + } + }) + + // Update a sample + // router.patch('/:project_id//samples/:sample_id', middlewareProjectValidation.rulesProjectUpdateModel, middlewareAuth.auth, async (req: Request, res: Response) => { + // try { + // const updated_project = await updateProjectSampleUseCase.execute((req as CustomRequest).token, { ...req.body, project_id: req.params.project_id }) + // res.status(200).send(updated_project) + // } catch (err) { + // console.log(err) + // if (err.message === "User is deleted") res.status(403).send({ errors: [err.message] }) + // else if (err.message === "Logged user cannot update this property or project") res.status(401).send({ errors: [err.message] }) + // else if (err.message === "Instrument not found") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "Member user cannot be use") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "Manager user cannot be use") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "Contact user cannot be use") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "Contact user must be either in members or managers") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "At least one user must be a manager") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "A user cannot be both a member and a manager") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "To update privilege part you must provide members, managers and contact, if you want to manage privileges more granuraly please use privilege endpoints") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "Please provide at least one property to update") res.status(401).send({ errors: [err.message] }) + // else if (err.message === "Privileges partially created, please check members, managers and contact") res.status(500).send({ errors: [err.message] }) + // else if (err.message === "Cannot find updated project") res.status(404).send({ errors: [err.message] }) + // else if (err.message === "Cannot find privileges") res.status(404).send({ errors: [err.message] }) + // else if (err.message.includes("Unauthorized or unexisting parameters")) res.status(401).send({ errors: [err.message] }) + // else if (err.message === "Please provide at least one valid parameter to update") res.status(401).send({ errors: [err.message] }) + // else res.status(500).send({ errors: ["Cannot update project"] }) + // } + // }) return router } \ No newline at end of file