diff --git a/functions/src/common/datastore.ts b/functions/src/common/datastore.ts index b7419da9f..3916ce4e5 100644 --- a/functions/src/common/datastore.ts +++ b/functions/src/common/datastore.ts @@ -17,6 +17,7 @@ import * as functions from 'firebase-functions'; import {firestore} from 'firebase-admin'; import {DocumentData, GeoPoint, QuerySnapshot} from 'firebase-admin/firestore'; +import {FieldNumbers} from '@ground/lib/dist/proto-field-numbers'; /** * @@ -119,7 +120,7 @@ export class Datastore { fetchSubmissionsByJobId(surveyId: string, jobId: string) { return this.db_ .collection(submissions(surveyId)) - .where('jobId', '==', jobId) + .where(FieldNumbers.Submission.job_id, '==', jobId) .get(); } @@ -133,7 +134,7 @@ export class Datastore { ): Promise> { return this.db_ .collection(lois(surveyId)) - .where('jobId', '==', jobId) + .where(FieldNumbers.LocationOfInterest.job_id, '==', jobId) .get(); } @@ -150,7 +151,11 @@ export class Datastore { loiId: string ): Promise { const submissionsRef = this.db_.collection(submissions(surveyId)); - const submissionsForLoiQuery = submissionsRef.where('loiId', '==', loiId); + const submissionsForLoiQuery = submissionsRef.where( + FieldNumbers.Submission.loi_id, + '==', + loiId + ); const snapshot = await submissionsForLoiQuery.count().get(); return snapshot.data().count; } diff --git a/functions/src/on-write-submission.spec.ts b/functions/src/on-write-submission.spec.ts index 63170d298..1ecaeb0c9 100644 --- a/functions/src/on-write-submission.spec.ts +++ b/functions/src/on-write-submission.spec.ts @@ -25,6 +25,7 @@ import * as functions from './index'; import {loi} from './common/datastore'; import {Firestore} from 'firebase-admin/firestore'; import {resetDatastore} from './common/context'; +import {FieldNumbers} from '@ground/lib'; const test = require('firebase-functions-test')(); @@ -62,7 +63,7 @@ describe('onWriteSubmission()', () => { .and.returnValue({ where: jasmine .createSpy('where') - .withArgs('loiId', '==', loiId) + .withArgs(FieldNumbers.Submission.loi_id, '==', loiId) .and.returnValue(newCountQuery(count)), } as any); } diff --git a/lib/src/firestore-to-proto.ts b/lib/src/firestore-to-proto.ts index dcaa129a7..3715491d8 100644 --- a/lib/src/firestore-to-proto.ts +++ b/lib/src/firestore-to-proto.ts @@ -118,6 +118,8 @@ function toFieldValue( case 'bool': case 'double': return firestoreValue; + case '.google.protobuf.Timestamp': + return toMessageOrEnumValue(['google', 'protobuf'], 'Timestamp', firestoreValue); default: return toMessageOrEnumValue(messageTypePath, fieldType, firestoreValue); } diff --git a/lib/src/index.ts b/lib/src/index.ts index 96f2cfe70..e2e4e3dc4 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -17,3 +17,4 @@ export {toDocumentData} from './proto-to-firestore'; export {toMessage} from './firestore-to-proto'; export {deleteEmpty, isEmpty} from './obj-util'; +export {FieldNumbers} from './proto-field-numbers'; diff --git a/lib/src/proto-field-numbers.ts b/lib/src/proto-field-numbers.ts new file mode 100644 index 000000000..acfd53f18 --- /dev/null +++ b/lib/src/proto-field-numbers.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2024 The Ground Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const FieldNumbers = { + LocationOfInterest: { + job_id: '2', + owner_id: '5', + source: '9' + }, + Submission: { + loi_id: '2', + job_id: '4', + owner_id: '5' + }, + Survey: { + acl: '4', + owner_id: '5' + } +}; diff --git a/package-lock.json b/package-lock.json index 163a9c806..59baab7b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23600,7 +23600,8 @@ }, "node_modules/long": { "version": "5.2.3", - "license": "Apache-2.0" + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -32887,6 +32888,7 @@ "functions": "^1.0.9", "hammerjs": "^2.0.8", "immutable": "^4.3.6", + "long": "^5.2.3", "ngx-autosize-input": "^17", "ngx-color": "^9.0.0", "rxjs": "^7.5.7", diff --git a/web/package.json b/web/package.json index 84efba402..f31359c53 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,7 @@ "build-all-and-test": "npm run build-all && npm run test", "watch": "npm run build -- --watch", "start": "npm run build && ng serve -c $npm_config_config", - "build-and-start": "npm run build && npm run start", + "build-and-start": "npm run build && npm run start", "build-all-and-start": "npm run build-all && npm run start", "pretest": "./pretest.sh", "test": "ng test", @@ -50,6 +50,7 @@ "functions": "^1.0.9", "hammerjs": "^2.0.8", "immutable": "^4.3.6", + "long": "^5.2.3", "ngx-autosize-input": "^17", "ngx-color": "^9.0.0", "rxjs": "^7.5.7", diff --git a/web/src/app/converters/firebase-data-converter.spec.ts b/web/src/app/converters/firebase-data-converter.spec.ts index fe83f2773..bc231c5a6 100644 --- a/web/src/app/converters/firebase-data-converter.spec.ts +++ b/web/src/app/converters/firebase-data-converter.spec.ts @@ -14,141 +14,11 @@ * limitations under the License. */ -import {Timestamp} from '@angular/fire/firestore'; -import {List, Map} from 'immutable'; - -import {Job} from 'app/models/job.model'; import {Role} from 'app/models/role.model'; -import {Submission} from 'app/models/submission/submission.model'; -import { - Cardinality, - MultipleChoice, -} from 'app/models/task/multiple-choice.model'; -import {Option} from 'app/models/task/option.model'; -import {Task, TaskType} from 'app/models/task/task.model'; import {FirebaseDataConverter} from './firebase-data-converter'; -class MockFirebaseData { - static submission001 = { - created: { - clientTimestamp: undefined, - serverTimestamp: undefined, - user: { - displayName: 'Creator', - email: 'creator@test.com', - id: 'creator001', - }, - }, - lastModified: { - clientTimestamp: undefined, - serverTimestamp: undefined, - user: { - displayName: 'Modifier', - email: 'modifier@test.com', - id: 'modifier001', - }, - }, - loiId: 'loi001', - jobId: 'job001', - data: { - task001: 'text result', - task002: ['option001', 'option002'], - task003: 123, - task004: new Timestamp(1641533340, 0), - task005: new Timestamp(1641534444, 0), - }, - }; -} - -class MockModel { - static task001: Task = new Task( - 'task001', - TaskType.TEXT, - 'Text Field', - /*required=*/ true, - 0 - ); - - static task002: Task = new Task( - 'task002', - TaskType.MULTIPLE_CHOICE, - 'Multiple Select', - /*required=*/ true, - 1, - new MultipleChoice( - Cardinality.SELECT_MULTIPLE, - List([ - new Option( - 'option001', - 'code001', - 'option 1', - /* index= */ - 0 - ), - new Option( - 'option002', - 'code002', - 'option 2', - /* index= */ - 0 - ), - ]) - ) - ); - - static task003: Task = new Task( - 'task003', - TaskType.NUMBER, - 'How many sloths are there?', - /*required=*/ true, - 2 - ); - - static task004: Task = new Task( - 'task004', - TaskType.DATE, - 'What is the current date?', - /*required=*/ true, - 2 - ); - - static task005: Task = new Task( - 'task005', - TaskType.TIME, - 'What time is it?', - /*required=*/ true, - 2 - ); - - static job001: Job = new Job( - /* id= */ 'job001', - /* index= */ 0, - '#ffffff', - 'Test job', - Map({ - task001: MockModel.task001, - task002: MockModel.task002, - task003: MockModel.task003, - task004: MockModel.task004, - task005: MockModel.task005, - }) - ); -} - describe('FirebaseDataConverter', () => { - it('Submission converts back and forth without loosing data.', () => { - expect( - FirebaseDataConverter.submissionToJS( - FirebaseDataConverter.toSubmission( - MockModel.job001, - 'submission001', - MockFirebaseData.submission001 - ) as Submission - ) - ).toEqual(MockFirebaseData.submission001); - }); - describe('toRole()', () => { it('converts enums to strings', () => { expect(FirebaseDataConverter.toRoleId(Role.OWNER)).toEqual('OWNER'); diff --git a/web/src/app/converters/firebase-data-converter.ts b/web/src/app/converters/firebase-data-converter.ts index dd4817fe6..4d08a9305 100644 --- a/web/src/app/converters/firebase-data-converter.ts +++ b/web/src/app/converters/firebase-data-converter.ts @@ -65,7 +65,7 @@ const TASK_TYPE_STRINGS_BY_ENUM = Map( ); /** - * Helper to return either the keys of a dictionary, or if missing, returns an + * Helper to return either the keys of a dictionary, or if missing, an * empty array. */ function keys(dict?: {}): string[] { @@ -367,50 +367,6 @@ export class FirebaseDataConverter { return optionDoc; } - /** - * Converts the raw object representation deserialized from Firebase into an - * immutable Submission instance. - * - * @param data the source data in a dictionary keyed by string. - *

-   * {
-   *   loiId: 'loi123'
-   *   taskId: 'task001',
-   *   data: {
-   *     'task001': 'Result text',    // For 'text_field' tasks.
-   *     'task002': ['A', 'B'],       // For 'multiple_choice' tasks.
-   *      // ...
-   *   }
-   *   created: ,
-   *   lastModified: 
-   * }
-   * 
- */ - static toSubmission( - job: Job, - id: string, - data: DocumentData - ): Submission | Error { - if (!job.tasks) { - return Error( - 'Error converting to submission: job must contain at least once task' - ); - } - if (!data) { - return Error( - `Error converting to submission: submission ${id} does not have document data.` - ); - } - return new Submission( - id, - data.loiId, - job, - FirebaseDataConverter.toAuditInfo(data.created), - FirebaseDataConverter.toAuditInfo(data.lastModified), - FirebaseDataConverter.toResults(job, data) - ); - } - static submissionToJS(submission: Submission): {} { return { loiId: submission.loiId, @@ -446,82 +402,6 @@ export class FirebaseDataConverter { ); } - /** - * Extracts and converts from the raw Firebase object to a map of {@link Result}s keyed by task id. - * In case of error when converting from raw data to {@link Result}, logs the error and then ignores - * that one {@link Result}. - * - * @param job the job related to this submission data, {@link job.tasks} must not be null or undefined. - * @param data the source data in a dictionary keyed by string. - */ - private static toResults(job: Job, data: DocumentData): Map { - // TODO(#1288): Clean up remaining references to old responses field - // Support submissions that have results or responses fields instead of data - // before model change. - const submissionData = data.data ?? data.results ?? data.responses; - return Map( - keys(submissionData) - .map((taskId: string) => { - return [ - taskId as string, - FirebaseDataConverter.toResult( - submissionData[taskId], - job.tasks!.get(taskId) - ), - ]; - }) - .filter(([_, resultOrError]) => - DataStoreService.filterAndLogError( - resultOrError as Result | Error - ) - ) - .map(([k, v]) => [k, v] as [string, Result]) - ); - } - - private static toResult( - resultValue: number | string | List, - task?: Task - ): Result | Error { - try { - if (typeof resultValue === 'string') { - return new Result(resultValue as string); - } - if (typeof resultValue === 'number') { - return new Result(resultValue as number); - } - if (resultValue instanceof Array) { - return new Result( - List( - resultValue - .filter(optionId => !optionId.startsWith('[')) - .map( - optionId => - task?.getMultipleChoiceOption(optionId) || - new Option(optionId, optionId, optionId, -1) - ) - ) - ); - } - if (resultValue instanceof Timestamp) { - return new Result(resultValue.toDate()); - } - const geometry = toGeometry(resultValue); - if ( - geometry instanceof Point || - geometry instanceof Polygon || - geometry instanceof MultiPolygon - ) { - return new Result(geometry); - } - return Error( - `Error converting to Result: unknown value type ${typeof resultValue}` - ); - } catch (err: any) { - return err instanceof Error ? err : new Error(err); - } - } - private static resultToJS(result: Result): {} { if (typeof result.value === 'string') { return result.value; @@ -538,31 +418,6 @@ export class FirebaseDataConverter { throw Error(`Unknown value type of ${result.value}`); } - /** - * Converts the raw object representation deserialized from Firebase into an - * immutable AuditInfo instance. - * - * @param data the source data in a dictionary keyed by string. - *

-   * {
-   *   user: {
-   *     id: ...,
-   *     displayName: ...,
-   *     email: ...
-   *   },
-   *   clientTimestamp: ...,
-   *   serverTimestamp: ...
-   * }
-   * 
- */ - private static toAuditInfo(data: DocumentData): AuditInfo { - return new AuditInfo( - data.user, - data.clientTimestamp?.toDate(), - data.serverTimestamp?.toDate() - ); - } - private static auditInfoToJs(auditInfo: AuditInfo): {} { return { user: FirebaseDataConverter.userToJs(auditInfo.user), diff --git a/web/src/app/converters/geometry-data-converter.ts b/web/src/app/converters/geometry-data-converter.ts index 33a96a58e..bf2e84199 100644 --- a/web/src/app/converters/geometry-data-converter.ts +++ b/web/src/app/converters/geometry-data-converter.ts @@ -62,7 +62,7 @@ function linearRingPbToModel(pb: Pb.ILinearRing): LinearRing { return new LinearRing(List(coordinates)); } -function coordinatesPbToModel(pb: Pb.ICoordinates): Coordinate { +export function coordinatesPbToModel(pb: Pb.ICoordinates): Coordinate { if (!pb.longitude || !pb.latitude) throw new Error(`Incomplete coordinate`); return new Coordinate(pb.longitude, pb.latitude); } diff --git a/web/src/app/converters/loi-data-converter.spec.ts b/web/src/app/converters/loi-data-converter.spec.ts index fe266ca1a..e274d8661 100644 --- a/web/src/app/converters/loi-data-converter.spec.ts +++ b/web/src/app/converters/loi-data-converter.spec.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import {DocumentData} from '@angular/fire/firestore'; import {GeoPoint} from 'firebase/firestore'; import {Map} from 'immutable'; diff --git a/web/src/app/converters/loi-data-converter.ts b/web/src/app/converters/loi-data-converter.ts index 5fe8c099f..b515db63a 100644 --- a/web/src/app/converters/loi-data-converter.ts +++ b/web/src/app/converters/loi-data-converter.ts @@ -27,7 +27,7 @@ import {geometryPbToModel} from './geometry-data-converter'; import Pb = GroundProtos.google.ground.v1beta1; /** - * Helper to return either the keys of a dictionary, or if missing, returns an + * Helper to return either the keys of a dictionary, or if missing, an * empty array. */ function keys(dict?: {}): string[] { diff --git a/web/src/app/converters/submission-data-converter.spec.ts b/web/src/app/converters/submission-data-converter.spec.ts new file mode 100644 index 000000000..164344eff --- /dev/null +++ b/web/src/app/converters/submission-data-converter.spec.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2024 The Ground Authors. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Timestamp} from '@angular/fire/firestore'; +import {Map} from 'immutable'; + +import {Job} from 'app/models/job.model'; +import {Submission} from 'app/models/submission/submission.model'; + +import {FirebaseDataConverter} from './firebase-data-converter'; +import {LegacySubmissionDataConverter} from './submission-data-converter'; + +const job001: Job = new Job( + /* id= */ 'job001', + /* index= */ 0, + '#ffffff', + 'Test job', + Map({}) +); + +const submission001 = { + created: { + clientTimestamp: undefined, + serverTimestamp: undefined, + user: { + displayName: 'Creator', + email: 'creator@test.com', + id: 'creator001', + }, + }, + lastModified: { + clientTimestamp: undefined, + serverTimestamp: undefined, + user: { + displayName: 'Modifier', + email: 'modifier@test.com', + id: 'modifier001', + }, + }, + loiId: 'loi001', + jobId: 'job001', + data: { + task001: 'text result', + task002: ['option001', 'option002'], + task003: 123, + task004: new Timestamp(1641533340, 0), + task005: new Timestamp(1641534444, 0), + }, +}; + +describe('toSubmission', () => { + it('Submission converts back and forth without losing data', () => { + expect( + FirebaseDataConverter.submissionToJS( + LegacySubmissionDataConverter.toSubmission( + job001, + 'submission001', + submission001 + ) as Submission + ) + ).toEqual(submission001); + }); +}); diff --git a/web/src/app/converters/submission-data-converter.ts b/web/src/app/converters/submission-data-converter.ts new file mode 100644 index 000000000..cd6532d62 --- /dev/null +++ b/web/src/app/converters/submission-data-converter.ts @@ -0,0 +1,306 @@ +/** + * Copyright 2024 The Ground Authors. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {DocumentData, Timestamp} from '@angular/fire/firestore'; +import {toMessage} from '@ground/lib'; +import {GroundProtos} from '@ground/proto'; +import {List, Map} from 'immutable'; +import Long from 'long'; + +import {toGeometry} from 'app/converters/geometry-converter'; +import {AuditInfo} from 'app/models/audit-info.model'; +import {MultiPolygon} from 'app/models/geometry/multi-polygon'; +import {Point} from 'app/models/geometry/point'; +import {Polygon} from 'app/models/geometry/polygon'; +import {Job} from 'app/models/job.model'; +import {Result} from 'app/models/submission/result.model'; +import { + Submission, + SubmissionData, +} from 'app/models/submission/submission.model'; +import {Option} from 'app/models/task/option.model'; +import {Task} from 'app/models/task/task.model'; +import {User} from 'app/models/user.model'; +import {DataStoreService} from 'app/services/data-store/data-store.service'; + +import { + coordinatesPbToModel, + geometryPbToModel, +} from './geometry-data-converter'; + +import Pb = GroundProtos.google.ground.v1beta1; + +/** + * Helper to return either the keys of a dictionary, or if missing, an + * empty array. + */ +function keys(dict?: {}): string[] { + return Object.keys(dict || {}); +} + +function timestampToInt( + timestamp: GroundProtos.google.protobuf.ITimestamp | null | undefined +): number { + if (!timestamp) return 0; + + return ( + (Long.isLong(timestamp.seconds) + ? timestamp.seconds.toInt() + : timestamp.seconds || 0) * 1000 + ); +} + +function createOtherOption(optionId: string, index: number) { + return new Option('otherOption', 'otherOption', optionId.slice(1, -1), index); +} + +export function submissionDocToModel( + job: Job, + id: string, + data: DocumentData +): Submission | Error { + // Use old converter if document doesn't include `job_id` using the new + // proto-based format. + if (!data['4']) { + return LegacySubmissionDataConverter.toSubmission(job, id, data); + } + const pb = toMessage(data, Pb.Submission) as Pb.Submission; + if (!pb.jobId) return Error(`Missing job_id in submission ${id}`); + if (!pb.loiId) return Error(`Missing loi_id in loi ${id}`); + return new Submission( + id, + pb.loiId, + job, + authInfoPbToModel(pb.created!), + authInfoPbToModel(pb.lastModified!), + taskDataPbToModel(pb.taskData, job) + ); +} + +function authInfoPbToModel(pb: Pb.IAuditInfo): AuditInfo { + return new AuditInfo( + new User(pb.userId!, '', true, pb.displayName!, pb.photoUrl!), + new Date(timestampToInt(pb.clientTimestamp)), + new Date(timestampToInt(pb.serverTimestamp)) + ); +} + +function taskDataPbToModel(pb: Pb.ITaskData[], job: Job): SubmissionData { + const submissionData: {[k: string]: Result} = {}; + + pb.forEach(taskData => { + const task = job.tasks?.get(taskData.taskId!); + + if (!task) return; + + let value = null; + + const { + textResponse, + numberResponse, + dateTimeResponse, + multipleChoiceResponses, + drawGeometryResult, + captureLocationResult, + takePhotoResult, + } = taskData; + + if (textResponse) value = textResponse.text; + else if (numberResponse) value = numberResponse.number; + else if (dateTimeResponse) + value = new Date(timestampToInt(dateTimeResponse.dateTime)); + else if (multipleChoiceResponses) { + value = + task.multipleChoice?.options.filter(({id: optionId}) => + multipleChoiceResponses!.selectedOptionIds?.includes(optionId) + ) || List([]); + + if ( + task.multipleChoice?.hasOtherOption && + multipleChoiceResponses!.otherText + ) { + value = value.push( + createOtherOption( + multipleChoiceResponses!.otherText, + task.multipleChoice?.options.size + ) + ); + } + } else if (drawGeometryResult) + value = geometryPbToModel(drawGeometryResult.geometry!) as Polygon; + else if (captureLocationResult) + value = new Point( + coordinatesPbToModel(captureLocationResult.coordinates!) + ); + else if (takePhotoResult) value = takePhotoResult.photoPath; + else throw new Error('Error converting to Submission: invalid task data'); + + submissionData[task.id] = new Result(value!); + }); + + return Map(submissionData); +} + +export class LegacySubmissionDataConverter { + /** + * Converts the raw object representation deserialized from Firebase into an + * immutable Submission instance. + * + * @param data the source data in a dictionary keyed by string. + *

+   * {
+   *   loiId: 'loi123'
+   *   taskId: 'task001',
+   *   data: {
+   *     'task001': 'Result text',    // For 'text_field' tasks.
+   *     'task002': ['A', 'B'],       // For 'multiple_choice' tasks.
+   *      // ...
+   *   }
+   *   created: ,
+   *   lastModified: 
+   * }
+   * 
+ */ + static toSubmission( + job: Job, + id: string, + data: DocumentData + ): Submission | Error { + if (!job.tasks) { + return Error( + 'Error converting to submission: job must contain at least once task' + ); + } + if (!data) { + return Error( + `Error converting to submission: submission ${id} does not have document data.` + ); + } + return new Submission( + id, + data.loiId, + job, + LegacySubmissionDataConverter.toAuditInfo(data.created), + LegacySubmissionDataConverter.toAuditInfo(data.lastModified), + LegacySubmissionDataConverter.toResults(job, data) + ); + } + + /** + * Converts the raw object representation deserialized from Firebase into an + * immutable AuditInfo instance. + * + * @param data the source data in a dictionary keyed by string. + *

+   * {
+   *   user: {
+   *     id: ...,
+   *     displayName: ...,
+   *     email: ...
+   *   },
+   *   clientTimestamp: ...,
+   *   serverTimestamp: ...
+   * }
+   * 
+ */ + private static toAuditInfo(data: DocumentData): AuditInfo { + return new AuditInfo( + data.user, + data.clientTimestamp?.toDate(), + data.serverTimestamp?.toDate() + ); + } + + /** + * Extracts and converts from the raw Firebase object to a map of {@link Result}s keyed by task id. + * In case of error when converting from raw data to {@link Result}, logs the error and then ignores + * that one {@link Result}. + * + * @param job the job related to this submission data, {@link job.tasks} must not be null or undefined. + * @param data the source data in a dictionary keyed by string. + */ + private static toResults(job: Job, data: DocumentData): Map { + // TODO(#1288): Clean up remaining references to old responses field + // Support submissions that have results or responses fields instead of data + // before model change. + const submissionData = data.data ?? data.results ?? data.responses; + return Map( + keys(submissionData) + .map((taskId: string) => { + return [ + taskId as string, + LegacySubmissionDataConverter.toResult( + submissionData[taskId], + job.tasks!.get(taskId) + ), + ]; + }) + .filter(([_, resultOrError]) => + DataStoreService.filterAndLogError( + resultOrError as Result | Error + ) + ) + .map(([k, v]) => [k, v] as [string, Result]) + ); + } + + private static toResult( + resultValue: + | number + | string + | string[] + | Timestamp + | {type?: string; geometry?: {type: string}}, + task?: Task + ): Result | Error { + try { + if (typeof resultValue === 'number') { + return new Result(resultValue as number); + } else if (typeof resultValue === 'string') { + return new Result(resultValue as string); + } else if (resultValue instanceof Array) { + return new Result( + List( + resultValue.map(optionId => { + if (optionId.startsWith('[')) + return createOtherOption(optionId, resultValue.length); + else + return ( + task?.getMultipleChoiceOption(optionId) || + new Option(optionId, optionId, optionId, -1) + ); + }) + ) + ); + } else if (resultValue instanceof Timestamp) { + return new Result(resultValue.toDate()); + } else { + const geometry = toGeometry(resultValue.geometry || resultValue); + if ( + geometry instanceof Point || + geometry instanceof Polygon || + geometry instanceof MultiPolygon + ) { + return new Result(geometry); + } + } + return Error( + `Error converting to Result: unknown value type ${typeof resultValue}` + ); + } catch (err) { + return err instanceof Error ? err : new Error(err as string); + } + } +} diff --git a/web/src/app/services/data-store/data-store.service.ts b/web/src/app/services/data-store/data-store.service.ts index 63d10f3d5..6e09dd830 100644 --- a/web/src/app/services/data-store/data-store.service.ts +++ b/web/src/app/services/data-store/data-store.service.ts @@ -25,6 +25,8 @@ import { deleteField, serverTimestamp, } from '@angular/fire/firestore'; +import {FieldNumbers} from '@ground/lib'; +import {GroundProtos} from '@ground/proto'; import {getDownloadURL, getStorage, ref} from 'firebase/storage'; import {List, Map} from 'immutable'; import {Observable, combineLatest, firstValueFrom} from 'rxjs'; @@ -38,6 +40,7 @@ import { newSurveyToDocument, partialSurveyToDocument, } from 'app/converters/proto-model-converter'; +import {submissionDocToModel} from 'app/converters/submission-data-converter'; import {Job} from 'app/models/job.model'; import {LocationOfInterest} from 'app/models/loi.model'; import {Role} from 'app/models/role.model'; @@ -46,6 +49,11 @@ import {Survey} from 'app/models/survey.model'; import {Task} from 'app/models/task/task.model'; import {User} from 'app/models/user.model'; +import Pb = GroundProtos.google.ground.v1beta1; + +const Source = Pb.LocationOfInterest.Source; +const AclRole = Pb.Role; + const SURVEYS_COLLECTION_NAME = 'surveys'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -130,7 +138,15 @@ export class DataStoreService { loadAccessibleSurveys$(userEmail: string): Observable> { return this.db .collection(SURVEYS_COLLECTION_NAME, ref => - ref.where(new FieldPath('acl', userEmail), 'in', Object.keys(Role)) + ref.where(new FieldPath(FieldNumbers.Survey.acl, userEmail), 'in', [ + AclRole.VIEWER, + AclRole.DATA_COLLECTOR, + AclRole.SURVEY_ORGANIZER, + Role.OWNER, + Role.SURVEY_ORGANIZER, + Role.DATA_COLLECTOR, + Role.VIEWER, + ]) ) .snapshotChanges() .pipe( @@ -277,7 +293,7 @@ export class DataStoreService { private async deleteAllSubmissionsInJob(surveyId: string, jobId: string) { const submissions = this.db.collection( `${SURVEYS_COLLECTION_NAME}/${surveyId}/submissions`, - ref => ref.where('jobId', '==', jobId) + ref => ref.where(FieldNumbers.Submission.job_id, '==', jobId) ); const querySnapshot = await firstValueFrom(submissions.get()); return await Promise.all(querySnapshot.docs.map(doc => doc.ref.delete())); @@ -289,7 +305,7 @@ export class DataStoreService { ) { const submissions = this.db.collection( `${SURVEYS_COLLECTION_NAME}/${surveyId}/submissions`, - ref => ref.where('loiId', '==', loiId) + ref => ref.where(FieldNumbers.Submission.loi_id, '==', loiId) ); const querySnapshot = await firstValueFrom(submissions.get()); return await Promise.all(querySnapshot.docs.map(doc => doc.ref.delete())); @@ -301,7 +317,7 @@ export class DataStoreService { ) { const loisInJob = this.db.collection( `${SURVEYS_COLLECTION_NAME}/${surveyId}/lois`, - ref => ref.where('jobId', '==', jobId) + ref => ref.where(FieldNumbers.LocationOfInterest.job_id, '==', jobId) ); const querySnapshot = await firstValueFrom(loisInJob.get()); return await Promise.all(querySnapshot.docs.map(doc => doc.ref.delete())); @@ -369,7 +385,7 @@ export class DataStoreService { */ getAccessibleLois$( {id: surveyId}: Survey, - userEmail: string, + userId: string, canManageSurvey: boolean ): Observable> { if (canManageSurvey) { @@ -379,25 +395,30 @@ export class DataStoreService { .pipe(map(this.toLocationsOfInterest)); } - const predefinedLois = this.db.collection( + const importedLois = this.db.collection( `${SURVEYS_COLLECTION_NAME}/${surveyId}/lois`, - ref => ref.where('predefined', 'in', [true, null]) + ref => + ref.where(FieldNumbers.LocationOfInterest.source, '==', Source.IMPORTED) ); - const userLois = this.db.collection( + const fieldData = this.db.collection( `${SURVEYS_COLLECTION_NAME}/${surveyId}/lois`, ref => ref - .where('predefined', '==', false) - .where('created.user.email', '==', userEmail) + .where( + FieldNumbers.LocationOfInterest.source, + '==', + Source.FIELD_DATA + ) + .where(FieldNumbers.LocationOfInterest.owner_id, '==', userId) ); return combineLatest([ - predefinedLois.valueChanges({idField: 'id'}), - userLois.valueChanges({idField: 'id'}), + importedLois.valueChanges({idField: 'id'}), + fieldData.valueChanges({idField: 'id'}), ]).pipe( - map(([predefinedLois, userLois]) => - this.toLocationsOfInterest(predefinedLois.concat(userLois)) + map(([predefinedLois, fieldData]) => + this.toLocationsOfInterest(predefinedLois.concat(fieldData)) ) ); } @@ -414,12 +435,12 @@ export class DataStoreService { getAccessibleSubmissions$( survey: Survey, loi: LocationOfInterest, - userEmail: string, + userId: string, canManageSurvey: boolean ): Observable> { return this.db .collection(`${SURVEYS_COLLECTION_NAME}/${survey.id}/submissions`, ref => - this.canViewSubmissions(ref, loi.id, userEmail, canManageSurvey) + this.canViewSubmissions(ref, loi.id, userId, canManageSurvey) ) .valueChanges({idField: 'id'}) .pipe( @@ -427,11 +448,7 @@ export class DataStoreService { List( array .map(obj => - FirebaseDataConverter.toSubmission( - survey.getJob(loi.jobId)!, - obj.id, - obj - ) + submissionDocToModel(survey.getJob(loi.jobId)!, obj.id, obj) ) .filter(DataStoreService.filterAndLogError) .map(submission => submission as Submission) @@ -452,7 +469,7 @@ export class DataStoreService { .get() .pipe( map(doc => - FirebaseDataConverter.toSubmission( + submissionDocToModel( survey.getJob(loi.jobId)!, doc.id, doc.data()! as DocumentData @@ -579,13 +596,13 @@ export class DataStoreService { private canViewSubmissions( ref: CollectionReference, loiId: string, - userEmail: string, + userId: string, canManageSurvey: boolean ) { return canManageSurvey - ? ref.where('loiId', '==', loiId) + ? ref.where(FieldNumbers.Submission.loi_id, '==', loiId) : ref - .where('loiId', '==', loiId) - .where('lastModified.user.email', '==', userEmail); + .where(FieldNumbers.Submission.loi_id, '==', loiId) + .where(FieldNumbers.Submission.owner_id, '==', userId); } } diff --git a/web/src/app/services/loi/loi.service.ts b/web/src/app/services/loi/loi.service.ts index c8137d96a..2ba2bd0b2 100644 --- a/web/src/app/services/loi/loi.service.ts +++ b/web/src/app/services/loi/loi.service.ts @@ -53,7 +53,7 @@ export class LocationOfInterestService { ? of(List()) : dataStore.getAccessibleLois$( survey, - user.email, + user.id, this.surveyService.canManageSurvey() ) ) diff --git a/web/src/app/services/submission/submission.service.ts b/web/src/app/services/submission/submission.service.ts index 8b25a9efb..9080e19b5 100644 --- a/web/src/app/services/submission/submission.service.ts +++ b/web/src/app/services/submission/submission.service.ts @@ -132,7 +132,7 @@ export class SubmissionService { this.dataStore.getAccessibleSubmissions$( survey, loi, - user.email, + user.id, this.surveyService.canManageSurvey() ) )