diff --git a/firestore/firestore.indexes.json b/firestore/firestore.indexes.json index 0e3f2d6b6..84b69bc85 100644 --- a/firestore/firestore.indexes.json +++ b/firestore/firestore.indexes.json @@ -1,3 +1,32 @@ { - "indexes": [] + "indexes": [ + { + "collectionGroup": "lois", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "`2`", // job_id + "order": "ASCENDING" + }, + { + "fieldPath": "`1`", // id + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "submissions", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "`4`", // job_id + "order": "ASCENDING" + }, + { + "fieldPath": "`2`", // loi_id + "order": "ASCENDING" + } + ] + } + ] } diff --git a/functions/src/common/datastore.ts b/functions/src/common/datastore.ts index 6eaf0534d..9bbdcf702 100644 --- a/functions/src/common/datastore.ts +++ b/functions/src/common/datastore.ts @@ -21,6 +21,8 @@ import {registry} from '@ground/lib'; import {GroundProtos} from '@ground/proto'; import Pb = GroundProtos.ground.v1beta1; +import {leftOuterJoinSorted, QueryIterator} from './query-iterator'; + const l = registry.getFieldIds(Pb.LocationOfInterest); const sb = registry.getFieldIds(Pb.Submission); @@ -132,66 +134,61 @@ export class Datastore { return this.db_.doc(job(surveyId, jobId)).get(); } - fetchAccessibleSubmissionsByJobId( - surveyId: string, - jobId: string, - userId?: string - ) { - if (!userId) { - return this.db_ - .collection(submissions(surveyId)) - .where(sb.jobId, '==', jobId) - .get(); - } else { - return this.db_ - .collection(submissions(surveyId)) - .where(sb.jobId, '==', jobId) - .where(sb.ownerId, '==', userId) - .get(); - } - } - fetchLocationOfInterest(surveyId: string, loiId: string) { return this.fetchDoc_(loi(surveyId, loiId)); } - async fetchAccessibleLocationsOfInterestByJobId( - surveyId: string, - jobId: string, - userId?: string - ): Promise { - if (!userId) { - return ( - await this.db_ - .collection(lois(surveyId)) - .where(l.jobId, '==', jobId) - .get() - ).docs; - } else { - const importedLois = this.db_ - .collection(lois(surveyId)) - .where(l.jobId, '==', jobId) - .where(l.source, '==', Pb.LocationOfInterest.Source.IMPORTED); - - const fieldDataLois = this.db_ - .collection(lois(surveyId)) - .where(l.jobId, '==', jobId) - .where(l.source, '==', Pb.LocationOfInterest.Source.FIELD_DATA) - .where(l.ownerId, '==', userId); - - const [importedLoisSnapshot, fieldDataLoisSnapshot] = await Promise.all([ - importedLois.get(), - fieldDataLois.get(), - ]); - - return [...importedLoisSnapshot.docs, ...fieldDataLoisSnapshot.docs]; - } + fetchLocationsOfInterest(surveyId: string, jobId: string) { + return this.db_ + .collection(lois(surveyId)) + .where(l.jobId, '==', jobId) + .get(); } fetchSheetsConfig(surveyId: string) { return this.fetchDoc_(`${survey(surveyId)}/sheets/config`); } + /** + * Fetches Location of Interests (LOIs) and their associated submissions for a given survey and job. + * + * @param surveyId The ID of the survey. + * @param jobId The ID of the job. + * @param ownerId The optional ID of the owner to filter submissions by. + * @param page The page number for pagination (used with the `QueryIterator`). + * @returns A Promise that resolves to an array of joined LOI and submission documents. + */ + async fetchLoisSubmissions( + surveyId: string, + jobId: string, + ownerId: string | undefined, + page: number + ) { + const loisQuery = this.db_ + .collection(lois(surveyId)) + .where(l.jobId, '==', jobId) + .orderBy(l.id); + let submissionsQuery = this.db_ + .collection(submissions(surveyId)) + .where(sb.jobId, '==', jobId) + .orderBy(sb.loiId); + if (ownerId) { + submissionsQuery = submissionsQuery.where(sb.ownerId, '==', ownerId); + } + const loisIterator = new QueryIterator(loisQuery, page, l.id); + const submissionsIterator = new QueryIterator( + submissionsQuery, + page, + sb.loiId + ); + return leftOuterJoinSorted( + loisIterator, + loiDoc => loiDoc.get(l.id), + submissionsIterator, + submissionDoc => submissionDoc.get(sb.loiId) + ); + } + async insertLocationOfInterest(surveyId: string, loiDoc: DocumentData) { await this.db_.doc(survey(surveyId)).collection('lois').add(loiDoc); } diff --git a/functions/src/common/query-iterator.ts b/functions/src/common/query-iterator.ts new file mode 100644 index 000000000..6ebc93df0 --- /dev/null +++ b/functions/src/common/query-iterator.ts @@ -0,0 +1,148 @@ +/** + * 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 { + Query, + QueryDocumentSnapshot, + QuerySnapshot, +} from 'firebase-admin/firestore'; + +/** + * An asynchronous iterator class that allows for iterating over the results of a Firestore query in batches. + */ +export class QueryIterator implements AsyncIterator { + private querySnapshot: QuerySnapshot | null = null; + private currentIndex = 0; + private lastDocument: QueryDocumentSnapshot | null = null; + + /** + * Creates a new QueryIterator. + * + * @param query The Firestore query to iterate over. + * @param pageSize The number of documents to fetch in each batch. + * @param orderField The field to order documents by (optional). + */ + constructor( + private query: Query, + private pageSize: number, + private orderField: string + ) {} + + /** + * Fetches the next batch of documents and returns the next document in the iterator. + * + * @returns A promise that resolves to an `IteratorResult` object. The `value` property + * will be the next `QueryDocumentSnapshot` if there are more documents, or `undefined` + * if there are no more documents. The `done` property indicates whether there are + * more documents to iterate over. + */ + async next(): Promise> { + if ( + this.querySnapshot === null || + this.currentIndex >= this.querySnapshot.size + ) { + // Fetch next batch of documents + let q = this.query.limit(this.pageSize); + if (this.lastDocument) { + q = q.startAfter([this.lastDocument?.get(this.orderField)]); + } + this.querySnapshot = await q.get(); + this.currentIndex = 0; + } + if (this.querySnapshot.size > 0) { + const document = this.querySnapshot.docs[this.currentIndex++]; + this.lastDocument = document; // Update last document for next batch + return { + value: document, + done: false, + }; + } else { + return { + value: undefined, + done: true, + }; + } + } +} + +/** + * Performs a left outer join operation on two asynchronous iterators with sorting. + * + * This function iterates through an asynchronous iterator of left elements (`leftIterator`) + * and an asynchronous iterator of right elements (`rightIterator`). It performs a left outer join + * based on the keys extracted from each element using the provided functions `getLeftKey` and `getRightKey`. + * + * The function yields pairs of elements from the left and right iterators. If there's no matching element + * from the right iterator for a left element, the function yields a pair with the left element's value and + * `undefined` for the right element (left outer join behavior). + * + * Both iterators are expected to be sorted by their respective keys for optimal performance. + * + * @template T The type of elements in the left iterator. + * @template U The type of elements in the right iterator. + * + * @param leftIterator The asynchronous iterator of left elements. + * @param getLeftKey A function that extracts the key for comparison from a left element. + * @param rightIterator The asynchronous iterator of right elements. + * @param getRightKey A function that extracts the key for comparison from a right element. + * + * @returns An asynchronous generator that yields pairs of elements from the left and right iterators. + * Each pair is an array containing the left element's value and the matching right element's value + * (or `undefined` if no match is found). + */ +export async function* leftOuterJoinSorted( + leftIterator: AsyncIterator, + getLeftKey: (left: T) => any, + rightIterator: AsyncIterator, + getRightKey: (right: U) => any +): AsyncGenerator<[T, U | undefined]> { + let leftItem = await leftIterator.next(); + let rightItem = await rightIterator.next(); + let rightItemsFound = 0; + + // This loop iterates through the left iterator until it's exhausted. + // In each iteration, it compares the current left item's key with the current + // right item's key (if there's a right item remaining). + while (!leftItem.done) { + const leftKey = getLeftKey(leftItem.value); + const rightKey = rightItem.done ? undefined : getRightKey(rightItem.value); + // Check for these conditions: + // 1. Right iterator is done (no more items on the right). + // 2. Left item's key is less than the right item's key (mismatch). + if (rightItem.done || leftKey < rightKey) { + // The left item has no matching item on the right (or right iterator is done). + // If no matching items were found on the right side for the current left item + // (or the right iterator has reached its end), yield a pair + // consisting of the left item's value and undefined. + if (rightItemsFound === 0) yield [leftItem.value, undefined]; + // Move to the next left item and reset the counter for matches. + leftItem = await leftIterator.next(); + rightItemsFound = 0; + } else if (leftKey > rightKey) { + // The right item's key is less than the left item's key (mismatch). + // Advance the right iterator to find a possible match for the current left item. + rightItem = await rightIterator.next(); + } else { + // Match found! The keys of the left and right items are equal. + // Increment the counter for the number of matches found for the current left item. + rightItemsFound++; + // Yield a pair with the left item's value, the matching right item's value, + // and the current count of matches for the left item. + yield [leftItem.value, rightItem.value]; + rightItem = await rightIterator.next(); + } + } +} diff --git a/functions/src/export-csv.spec.ts b/functions/src/export-csv.spec.ts index 0550de3ef..be3c7ba9f 100644 --- a/functions/src/export-csv.spec.ts +++ b/functions/src/export-csv.spec.ts @@ -45,6 +45,7 @@ const d = registry.getFieldIds(Pb.TaskData); const mq = registry.getFieldIds(Pb.Task.MultipleChoiceQuestion); const op = registry.getFieldIds(Pb.Task.MultipleChoiceQuestion.Option); const cl = registry.getFieldIds(Pb.TaskData.CaptureLocationResult); +const a = registry.getFieldIds(Pb.AuditInfo); describe('exportCsv()', () => { let mockFirestore: Firestore; @@ -56,6 +57,14 @@ describe('exportCsv()', () => { [email]: SURVEY_ORGANIZER_ROLE, }, }; + const auditInfo = { + [a.userId]: userId, + [a.displayName]: 'display_name', + [a.emailAddress]: 'address@email.com', + [a.clientTimestamp]: {1: 1, 2: 0}, + [a.serverTimestamp]: {1: 1, 2: 0}, + }; + const emptyJob = {id: 'job123'}; const job1 = { id: 'job123', @@ -121,12 +130,13 @@ describe('exportCsv()', () => { }; const pointLoi1 = { id: 'loi100', + [l.id]: 'loi100', [l.jobId]: job1.id, [l.customTag]: 'POINT_001', [l.geometry]: { [g.point]: {[p.coordinates]: {[c.latitude]: 10.1, [c.longitude]: 125.6}}, }, - [l.submission_count]: 0, + [l.submissionCount]: 0, [l.source]: Pb.LocationOfInterest.Source.IMPORTED, [l.properties]: { name: {[pr.stringValue]: 'Dinagat Islands'}, @@ -135,6 +145,7 @@ describe('exportCsv()', () => { }; const pointLoi2 = { id: 'loi200', + [l.id]: 'loi200', [l.jobId]: job1.id, [l.customTag]: 'POINT_002', [l.geometry]: { @@ -148,6 +159,7 @@ describe('exportCsv()', () => { }; const submission1a = { id: '001a', + [s.id]: '001a', [s.loiId]: pointLoi1.id, [s.index]: 1, [s.jobId]: job1.id, @@ -171,6 +183,7 @@ describe('exportCsv()', () => { }; const submission1b = { id: '001b', + [s.id]: '001b', [s.loiId]: pointLoi1.id, [s.index]: 2, [s.jobId]: job1.id, @@ -193,9 +206,11 @@ describe('exportCsv()', () => { }, }, ], + [s.created]: auditInfo, }; const submission2a = { id: '002a', + [s.id]: '002a', [s.loiId]: pointLoi2.id, [s.index]: 1, [s.jobId]: job1.id, @@ -239,8 +254,8 @@ describe('exportCsv()', () => { expectedFilename: 'ground-export.csv', expectedCsv: [ '"system:index","geometry","name","area","data:contributor_name","data:contributor_email","data:created_client_timestamp","data:created_server_timestamp"', - '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,,,"1970-01-01T00:00:00.000Z","1970-01-01T00:00:00.000Z"', - '"POINT_002","POINT (8.3 47.05)","Luzern",,,,"1970-01-01T00:00:00.000Z","1970-01-01T00:00:00.000Z"', + '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,,,,', + '"POINT_002","POINT (8.3 47.05)","Luzern",,,,,', ], }, { @@ -254,10 +269,25 @@ describe('exportCsv()', () => { expectedCsv: [ '"system:index","geometry","name","area","data:What is the meaning of life?","data:How much?","data:When?","data:Which ones?","data:Where are you now?","data:Take a photo","data:contributor_name","data:contributor_email","data:created_client_timestamp","data:created_server_timestamp"', '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 1",42,,,,,,,"1970-01-01T00:00:00.000Z","1970-01-01T00:00:00.000Z"', - '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 2",,"2012-03-08T12:17:24.000Z",,,,,,"1970-01-01T00:00:00.000Z","1970-01-01T00:00:00.000Z"', + '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 2",,"2012-03-08T12:17:24.000Z",,,,"display_name","address@email.com","1970-01-01T00:00:01.000Z","1970-01-01T00:00:01.000Z"', '"POINT_002","POINT (8.3 47.05)","Luzern",,,,,"AAA,BBB,Other: other","POINT (45 -123)","http://photo/url",,,"1970-01-01T00:00:00.000Z","1970-01-01T00:00:00.000Z"', ], }, + { + desc: 'export points w and w/o submissions', + jobId: job1.id, + survey: survey, + jobs: [job1], + lois: [pointLoi1, pointLoi2], + submissions: [submission1a, submission1b], + expectedFilename: 'test-job.csv', + expectedCsv: [ + '"system:index","geometry","name","area","data:What is the meaning of life?","data:How much?","data:When?","data:Which ones?","data:Where are you now?","data:Take a photo","data:contributor_name","data:contributor_email","data:created_client_timestamp","data:created_server_timestamp"', + '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 1",42,,,,,,,"1970-01-01T00:00:00.000Z","1970-01-01T00:00:00.000Z"', + '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 2",,"2012-03-08T12:17:24.000Z",,,,"display_name","address@email.com","1970-01-01T00:00:01.000Z","1970-01-01T00:00:01.000Z"', + '"POINT_002","POINT (8.3 47.05)","Luzern",,,,,,,,,,,', + ], + }, ]; beforeEach(() => { diff --git a/functions/src/export-csv.ts b/functions/src/export-csv.ts index a5af84ffa..1c82368c8 100644 --- a/functions/src/export-csv.ts +++ b/functions/src/export-csv.ts @@ -22,17 +22,12 @@ import {getDatastore} from './common/context'; import * as HttpStatus from 'http-status-codes'; import {DecodedIdToken} from 'firebase-admin/auth'; import {List} from 'immutable'; -import {DocumentData} from 'firebase-admin/firestore'; -import {registry, timestampToInt, toMessage} from '@ground/lib'; +import {QuerySnapshot} from 'firebase-admin/firestore'; +import {timestampToInt, toMessage} from '@ground/lib'; import {GroundProtos} from '@ground/proto'; import {toGeoJsonGeometry} from '@ground/lib'; import Pb = GroundProtos.ground.v1beta1; -const sb = registry.getFieldIds(Pb.Submission); -const l = registry.getFieldIds(Pb.LocationOfInterest); - -/** A dictionary of submissions values (array) keyed by loi ID. */ -type SubmissionDict = {[key: string]: any[]}; /** * Iterates over all LOIs and submissions in a job, joining them @@ -56,8 +51,13 @@ export async function exportCsvHandler( res.status(HttpStatus.FORBIDDEN).send('Permission denied'); return; } - const canManageSurvey = canImport(user, surveyDoc); - console.log(`Exporting survey '${surveyId}', job '${jobId}'`); + const ownerId = canImport(user, surveyDoc) ? undefined : userId; + + console.log( + `Exporting survey '${surveyId}', job '${jobId}', owner '${ + ownerId || 'survey organizer' + }'` + ); const jobDoc = await db.fetchJob(surveyId, jobId); if (!jobDoc.exists || !jobDoc.data()) { @@ -73,12 +73,8 @@ export async function exportCsvHandler( } const {name: jobName} = job; const tasks = job.tasks.sort((a, b) => a.index! - b.index!); - const lois = await db.fetchAccessibleLocationsOfInterestByJobId( - surveyId, - jobId, - !canManageSurvey ? userId : undefined - ); - const loiProperties = getPropertyNames(lois); + const snapshot = await db.fetchLocationsOfInterest(surveyId, jobId); + const loiProperties = createProperySetFromSnapshot(snapshot); const headers = getHeaders(tasks, loiProperties); res.type('text/csv'); @@ -95,48 +91,30 @@ export async function exportCsvHandler( }); csvStream.pipe(res); - const submissionsByLoi = await getSubmissionsByLoi( - surveyId, - jobId, - !canManageSurvey ? userId : undefined - ); + const rows = await db.fetchLoisSubmissions(surveyId, jobId, ownerId, 50); - lois.forEach(loiDoc => { - const loi = toMessage(loiDoc.data(), Pb.LocationOfInterest); - if (loi instanceof Error) { - throw loi; + for await (const row of rows) { + try { + const [loiDoc, submissionDoc] = row; + const loi = toMessage(loiDoc.data(), Pb.LocationOfInterest); + if (loi instanceof Error) throw loi; + if (!isAccessibleLoi(loi, ownerId)) return; + if (submissionDoc) { + const submission = toMessage(submissionDoc.data(), Pb.Submission); + if (submission instanceof Error) throw submission; + writeRow(csvStream, loiProperties, tasks, loi, submission); + } else { + writeRow(csvStream, loiProperties, tasks, loi); + } + } catch (e) { + console.debug('Skipping row', e); } - // Submissions to be joined with the current LOI, resulting in one row - // per submission. For LOIs with no submissions, a single empty submission - // is added to ensure the LOI is represented in the output as a row with - // LOI fields, but no submission data. - const submissions = submissionsByLoi[loiDoc.id] || [{}]; - submissions.forEach(submissionDict => - writeSubmissions(csvStream, loiProperties, tasks, loi, submissionDict) - ); - }); + } + res.status(HttpStatus.OK); csvStream.end(); } -function writeSubmissions( - csvStream: csv.CsvFormatterStream, - loiProperties: Set, - tasks: Pb.ITask[], - loi: Pb.LocationOfInterest, - submissionDict: SubmissionDict -) { - try { - const submission = toMessage(submissionDict, Pb.Submission); - if (submission instanceof Error) { - throw submission; - } - writeRow(csvStream, loiProperties, tasks, loi, submission); - } catch (e) { - console.debug('Skipping row', e); - } -} - function getHeaders(tasks: Pb.ITask[], loiProperties: Set): string[] { const headers = [ 'system:index', @@ -151,41 +129,12 @@ function getHeaders(tasks: Pb.ITask[], loiProperties: Set): string[] { return headers.map(quote); } -/** - * Returns all submissions in the specified job, indexed by LOI ID. - * Note: Indexes submissions by LOI id in memory. This consumes more - * memory than iterating over and streaming both LOI and submission - * collections simultaneously, but it's easier to read and maintain. This - * function will need to be optimized to scale to larger datasets than - * can fit in memory. - */ -async function getSubmissionsByLoi( - surveyId: string, - jobId: string, - userId?: string -): Promise { - const db = getDatastore(); - const submissions = await db.fetchAccessibleSubmissionsByJobId( - surveyId, - jobId, - userId - ); - const submissionsByLoi: {[name: string]: any[]} = {}; - submissions.forEach(submission => { - const loiId = submission.get(sb.loiId) as string; - const arr: any[] = submissionsByLoi[loiId] || []; - arr.push(submission.data()); - submissionsByLoi[loiId] = arr; - }); - return submissionsByLoi; -} - function writeRow( csvStream: csv.CsvFormatterStream, loiProperties: Set, tasks: Pb.ITask[], loi: Pb.LocationOfInterest, - submission: Pb.Submission + submission?: Pb.Submission ) { if (!loi.geometry) { console.debug(`Skipping LOI ${loi.id} - missing geometry`); @@ -198,19 +147,23 @@ function writeRow( row.push(quote(toWkt(loi.geometry))); // Header: One column for each loi property (merged over all properties across all LOIs) getPropertiesByName(loi, loiProperties).forEach(v => row.push(quote(v))); - const {taskData: data} = submission; - // Header: One column for each task - tasks.forEach(task => row.push(quote(getValue(task, data)))); - // Header: contributor_username, contributor_email, created_client_timestamp, created_server_timestamp - const {created} = submission; - row.push(quote(created?.displayName)); - row.push(quote(created?.emailAddress)); - row.push( - quote(new Date(timestampToInt(created?.clientTimestamp)).toISOString()) - ); - row.push( - quote(new Date(timestampToInt(created?.serverTimestamp)).toISOString()) - ); + if (submission) { + const {taskData: data} = submission; + // Header: One column for each task + tasks.forEach(task => row.push(quote(getValue(task, data)))); + // Header: contributor_username, contributor_email, created_client_timestamp, created_server_timestamp + const {created} = submission; + row.push(quote(created?.displayName)); + row.push(quote(created?.emailAddress)); + row.push( + quote(new Date(timestampToInt(created?.clientTimestamp)).toISOString()) + ); + row.push( + quote(new Date(timestampToInt(created?.serverTimestamp)).toISOString()) + ); + } else { + row.concat(new Array(tasks.length + 4).fill('')); + } csvStream.write(row); } @@ -229,6 +182,14 @@ function toWkt(geometry: Pb.IGeometry): string { return geojsonToWKT(toGeoJsonGeometry(geometry)); } +/** + * Checks if a Location of Interest (LOI) is accessible to a given user. + */ +function isAccessibleLoi(loi: Pb.ILocationOfInterest, ownerId?: string) { + const isFieldData = loi.source === Pb.LocationOfInterest.Source.FIELD_DATA; + return ownerId ? isFieldData && loi.ownerId === ownerId : true; +} + /** * Returns the string or number representation of a specific task element result. */ @@ -322,8 +283,21 @@ function getFileName(jobName: string | null) { return `${fileBase}.csv`; } -function getPropertyNames(lois: DocumentData[]): Set { - return new Set(lois.flatMap(loi => Object.keys(loi.get(l.properties) || {}))); +function createProperySetFromSnapshot( + snapshot: QuerySnapshot, + ownerId?: string +): Set { + const allKeys = new Set(); + snapshot.forEach(doc => { + const loi = toMessage(doc.data(), Pb.LocationOfInterest); + if (loi instanceof Error) return; + if (!isAccessibleLoi(loi, ownerId)) return; + const properties = loi.properties; + for (const key of Object.keys(properties || {})) { + allKeys.add(key); + } + }); + return allKeys; } function getPropertiesByName( 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 3b21cec3e..36496eb39 100644 --- a/web/src/app/services/data-store/data-store.service.ts +++ b/web/src/app/services/data-store/data-store.service.ts @@ -535,8 +535,8 @@ export class DataStoreService { userId: string, canManageSurvey: boolean ) { - return canManageSurvey - ? ref.where(sb.loiId, '==', loiId) - : ref.where(sb.loiId, '==', loiId).where(sb.ownerId, '==', userId); + const query = ref.where(sb.loiId, '==', loiId); + + return canManageSurvey ? query : query.where(sb.ownerId, '==', userId); } }