diff --git a/e2e-tests/.eslintrc.json b/e2e-tests/.eslintrc.json
index 31faa98c8..52ca2f540 100644
--- a/e2e-tests/.eslintrc.json
+++ b/e2e-tests/.eslintrc.json
@@ -3,6 +3,7 @@
   "rules": {
     "node/no-unpublished-import": ["error", {
       "allowModules": ["jasmine"]
-    }]
+    }],
+    "eqeqeq": ["error", "always", {"null": "ignore"}]
   }
 }
diff --git a/functions/.eslintrc.json b/functions/.eslintrc.json
index bb72f1a11..dc554d679 100644
--- a/functions/.eslintrc.json
+++ b/functions/.eslintrc.json
@@ -6,5 +6,8 @@
   "parserOptions": {
     "sourceType": "module"
   },
-  "root": true
+  "root": true,
+  "rules": {
+    "eqeqeq": ["error", "always", {"null": "ignore"}]    
+  }
 }
diff --git a/functions/src/export-csv.spec.ts b/functions/src/export-csv.spec.ts
new file mode 100644
index 000000000..518738f9d
--- /dev/null
+++ b/functions/src/export-csv.spec.ts
@@ -0,0 +1,292 @@
+/**
+ * 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 {
+  createMockFirestore,
+  stubAdminApi,
+} from '@ground/lib/dist/testing/firestore';
+import {
+  createGetRequestSpy,
+  createResponseSpy,
+} from './testing/http-test-helpers';
+import {DecodedIdToken} from 'firebase-admin/auth';
+import HttpStatus from 'http-status-codes';
+import {OWNER_ROLE} from './common/auth';
+import {resetDatastore} from './common/context';
+import {Firestore} from 'firebase-admin/firestore';
+import {exportCsvHandler} from './export-csv';
+import {registry} from '@ground/lib';
+import {GroundProtos} from '@ground/proto';
+
+import Pb = GroundProtos.ground.v1beta1;
+const l = registry.getFieldIds(Pb.LocationOfInterest);
+const pr = registry.getFieldIds(Pb.LocationOfInterest.Property);
+const p = registry.getFieldIds(Pb.Point);
+const c = registry.getFieldIds(Pb.Coordinates);
+const g = registry.getFieldIds(Pb.Geometry);
+const s = registry.getFieldIds(Pb.Submission);
+const d = registry.getFieldIds(Pb.TaskData);
+const cl = registry.getFieldIds(Pb.TaskData.CaptureLocationResult);
+
+describe('exportCsv()', () => {
+  let mockFirestore: Firestore;
+  const jobId = 'job123';
+  const email = 'somebody@test.it';
+  const userId = 'user5000';
+  // TODO(#1758): Use new proto-based survey and job representation.
+  const survey1 = {
+    id: 'survey001',
+    name: 'Test survey 1',
+    acl: {
+      [email]: OWNER_ROLE,
+    },
+  };
+  const survey2 = {
+    id: 'survey002',
+    name: 'Test survey 2',
+    acl: {
+      [email]: OWNER_ROLE,
+    },
+    jobs: {
+      [jobId]: {
+        name: 'Test job',
+        tasks: {
+          task001: {
+            type: 'text_field',
+            label: 'What is the meaning of life?',
+          },
+          task002: {
+            type: 'number_field',
+            label: 'How much?',
+          },
+          task003: {
+            type: 'date_time_field',
+            label: 'When?',
+          },
+          task004: {
+            type: 'select_multiple',
+            label: 'Which ones?',
+            options: [
+              {
+                id: 'aaa',
+                label: 'AAA',
+              },
+              {
+                id: 'bbb',
+                label: 'BBB',
+              },
+            ],
+            hasOtherOption: true,
+          },
+          task005: {
+            type: 'capture_location',
+            label: 'Where are you now?',
+          },
+          task006: {
+            type: 'take_photo',
+            label: 'Take a photo',
+          },
+        },
+      },
+    },
+  };
+  const pointLoi1 = {
+    id: 'loi100',
+    [l.jobId]: jobId,
+    [l.customTag]: 'POINT_001',
+    [l.geometry]: {
+      [g.point]: {[p.coordinates]: {[c.latitude]: 10.1, [c.longitude]: 125.6}},
+    },
+    [l.submission_count]: 0,
+    [l.source]: Pb.LocationOfInterest.Source.IMPORTED,
+    [l.properties]: {
+      name: {[pr.stringValue]: 'Dinagat Islands'},
+      area: {[pr.numericValue]: 3.08},
+    },
+  };
+  const pointLoi2 = {
+    id: 'loi200',
+    [l.jobId]: jobId,
+    [l.customTag]: 'POINT_002',
+    [l.geometry]: {
+      [g.point]: {[p.coordinates]: {[c.latitude]: 47.05, [c.longitude]: 8.3}},
+    },
+    [l.submissionCount]: 0,
+    [l.source]: Pb.LocationOfInterest.Source.FIELD_DATA,
+    [l.properties]: {
+      name: {[pr.stringValue]: 'Luzern'},
+    },
+  };
+  const submission1a = {
+    id: '001a',
+    [s.loiId]: pointLoi1.id,
+    [s.index]: 1,
+    [s.jobId]: jobId,
+    [s.ownerId]: userId,
+    [s.taskData]: [
+      {
+        [d.id]: 'data001a',
+        [d.taskId]: 'task001',
+        [d.textResponse]: {
+          '1': 'Submission 1',
+        },
+      },
+      {
+        [d.id]: 'data002a',
+        [d.taskId]: 'task002',
+        [d.numberResponse]: {
+          '1': 42,
+        },
+      },
+    ],
+  };
+  const submission1b = {
+    id: '001b',
+    [s.loiId]: pointLoi1.id,
+    [s.index]: 2,
+    [s.jobId]: jobId,
+    [s.ownerId]: userId,
+    [s.taskData]: [
+      {
+        [d.id]: 'data001b',
+        [d.taskId]: 'task001',
+        [d.textResponse]: {
+          '1': 'Submission 2',
+        },
+      },
+      {
+        [d.id]: 'data003a',
+        [d.taskId]: 'task003',
+        [d.dateTimeResponse]: {
+          '1': {
+            '1': 1331209044, // seconds
+          },
+        },
+      },
+    ],
+  };
+  const submission2a = {
+    id: '002a',
+    [s.loiId]: pointLoi2.id,
+    [s.index]: 1,
+    [s.jobId]: jobId,
+    [s.ownerId]: userId,
+    [s.taskData]: [
+      {
+        [d.id]: 'data004',
+        [d.taskId]: 'task004',
+        [d.multipleChoiceResponses]: {
+          '1': ['aaa', 'bbb'],
+          '2': 'Other',
+        },
+      },
+      {
+        [d.id]: 'data005a',
+        [d.taskId]: 'task005',
+        [d.captureLocationResult]: {
+          [cl.coordinates]: {
+            [c.latitude]: -123,
+            [c.longitude]: 45,
+          },
+        },
+      },
+      {
+        [d.id]: 'data006b',
+        [d.taskId]: 'task006',
+        [d.takePhotoResult]: {
+          '1': 'http://photo/url',
+        },
+      },
+    ],
+  };
+  const testCases = [
+    {
+      desc: 'export points w/o submissions',
+      survey: survey1,
+      lois: [pointLoi1, pointLoi2],
+      submissions: [],
+      expectedFilename: 'ground-export.csv',
+      expectedCsv: [
+        '"system:index","geometry","name","area","data:contributor_name","data:contributor_email"',
+        '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,,',
+        '"POINT_002","POINT (8.3 47.05)","Luzern",,,',
+      ],
+    },
+    {
+      desc: 'export points w/submissions',
+      survey: survey2,
+      lois: [pointLoi1, pointLoi2],
+      submissions: [submission1a, submission1b, submission2a],
+      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"',
+        '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 1",42,,,,,,',
+        '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 2",,"2012-03-08T12:17:24.000Z",,,,,',
+        '"POINT_002","POINT (8.3 47.05)","Luzern",,,,,"AAA,BBB,Other","POINT (45 -123)","http://photo/url",,',
+      ],
+    },
+  ];
+
+  beforeEach(() => {
+    mockFirestore = createMockFirestore();
+    stubAdminApi(mockFirestore);
+  });
+
+  afterEach(() => {
+    resetDatastore();
+  });
+
+  testCases.forEach(
+    ({desc, survey, lois, submissions, expectedFilename, expectedCsv}) =>
+      it(desc, async () => {
+        // Populate database.
+        mockFirestore.doc(`surveys/${survey.id}`).set(survey);
+        lois?.forEach(({id, ...loi}) =>
+          mockFirestore.doc(`surveys/${survey.id}/lois/${id}`).set(loi)
+        );
+        submissions?.forEach(({id, ...submission}) =>
+          mockFirestore
+            .doc(`surveys/${survey.id}/submissions/${id}`)
+            .set(submission)
+        );
+
+        // Build mock request and response.
+        const req = await createGetRequestSpy({
+          url: '/exportCsv',
+          query: {
+            survey: survey.id,
+            job: jobId,
+          },
+        });
+        const chunks: string[] = [];
+        const res = createResponseSpy(chunks);
+
+        // Run export CSV handler.
+        await exportCsvHandler(req, res, {email} as DecodedIdToken);
+
+        // Check post-conditions.
+        expect(res.status).toHaveBeenCalledOnceWith(HttpStatus.OK);
+        expect(res.type).toHaveBeenCalledOnceWith('text/csv');
+        expect(res.setHeader).toHaveBeenCalledOnceWith(
+          'Content-Disposition',
+          `attachment; filename=${expectedFilename}`
+        );
+        const output = chunks.join('').trim();
+        const lines = output.split('\n');
+        expect(lines).toEqual(expectedCsv);
+      })
+  );
+});
diff --git a/functions/src/export-csv.ts b/functions/src/export-csv.ts
index d9c95d932..817e62a8d 100644
--- a/functions/src/export-csv.ts
+++ b/functions/src/export-csv.ts
@@ -20,10 +20,16 @@ import {canExport} from './common/auth';
 import {geojsonToWKT} from '@terraformer/wkt';
 import {getDatastore} from './common/context';
 import * as HttpStatus from 'http-status-codes';
-import {Datastore} from './common/datastore';
 import {DecodedIdToken} from 'firebase-admin/auth';
 import {List} from 'immutable';
 import {DocumentData, QuerySnapshot} from 'firebase-admin/firestore';
+import {registry, 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);
 
 // TODO(#1277): Use a shared model with web
 type Task = {
@@ -37,15 +43,9 @@ type Task = {
   readonly hasOtherOption?: boolean;
 };
 
-type Dict = {[key: string]: any};
-
 /** A dictionary of submissions values (array) keyed by loi ID. */
 type SubmissionDict = {[key: string]: any[]};
 
-// TODO(#1277): Use a shared model with web
-type LoiDocument =
-  FirebaseFirestore.QueryDocumentSnapshot<FirebaseFirestore.DocumentData>;
-
 /**
  * Iterates over all LOIs and submissions in a job, joining them
  * into a single table written to the response as a quote CSV file.
@@ -69,9 +69,10 @@ export async function exportCsvHandler(
   }
   console.log(`Exporting survey '${surveyId}', job '${jobId}'`);
 
+  // TODO(#1779): Get job metadata from  `/surveys/{surveyId}/jobs` instead.
   const jobs = survey.get('jobs') || {};
   const job = jobs[jobId] || {};
-  const jobName = job.name && (job.name['en'] as string);
+  const jobName = job.name;
   const tasksObject = (job['tasks'] as {[id: string]: Task}) || {};
   const tasks = new Map(Object.entries(tasksObject));
   const loiDocs = await db.fetchLocationsOfInterestByJobId(survey.id, jobId);
@@ -86,23 +87,50 @@ export async function exportCsvHandler(
   const csvStream = csv.format({
     delimiter: ',',
     headers,
-    includeEndRowDelimiter: true,
     rowDelimiter: '\n',
-    quoteColumns: true,
-    quote: '"',
+    includeEndRowDelimiter: true, // Add \n to last row in CSV
+    quote: false,
   });
   csvStream.pipe(res);
 
   const submissionsByLoi = await getSubmissionsByLoi(survey.id, jobId);
 
   loiDocs.forEach(loiDoc => {
-    submissionsByLoi[loiDoc.id]?.forEach(submission =>
-      writeRow(csvStream, loiProperties, tasks, loiDoc, submission)
+    const loi = toMessage(loiDoc.data(), Pb.LocationOfInterest);
+    if (loi instanceof Error) {
+      throw loi;
+    }
+    // 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<csv.Row, csv.Row>,
+  loiProperties: Set<string>,
+  tasks: Map<string, Task>,
+  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: Map<string, Task>,
   loiProperties: Set<string>
@@ -111,10 +139,11 @@ function getHeaders(
   headers.push('system:index');
   headers.push('geometry');
   headers.push(...loiProperties);
+  // TODO(#1936): Use `index` field to export columns in correct order.
   tasks.forEach(task => headers.push('data:' + task.label));
-  headers.push('data:contributor_username');
+  headers.push('data:contributor_name');
   headers.push('data:contributor_email');
-  return headers;
+  return headers.map(quote);
 }
 
 /**
@@ -133,7 +162,7 @@ async function getSubmissionsByLoi(
   const submissions = await db.fetchSubmissionsByJobId(surveyId, jobId);
   const submissionsByLoi: {[name: string]: any[]} = {};
   submissions.forEach(submission => {
-    const loiId = submission.get('loiId') as string;
+    const loiId = submission.get(sb.loiId) as string;
     const arr: any[] = submissionsByLoi[loiId] || [];
     arr.push(submission.data());
     submissionsByLoi[loiId] = arr;
@@ -145,107 +174,128 @@ function writeRow(
   csvStream: csv.CsvFormatterStream<csv.Row, csv.Row>,
   loiProperties: Set<string>,
   tasks: Map<string, Task>,
-  loiDoc: LoiDocument,
-  submission: SubmissionDict
+  loi: Pb.LocationOfInterest,
+  submission: Pb.Submission
 ) {
   const row = [];
   // Header: system:index
-  row.push(loiDoc.get('properties')?.id || '');
+  row.push(quote(loi.customTag));
   // Header: geometry
-  row.push(toWkt(loiDoc.get('geometry')) || '');
+  if (!loi.geometry) {
+    console.debug(`Skipping LOI ${loi.id} - missing geometry`);
+    return;
+  }
+  row.push(quote(toWkt(loi.geometry)));
   // Header: One column for each loi property (merged over all properties across all LOIs)
-  row.push(...getPropertiesByName(loiDoc, loiProperties));
-  // TODO(#1288): Clean up remaining references to old responses field
-  const data =
-    submission['data'] ||
-    submission['responses'] ||
-    submission['results'] ||
-    {};
+  getPropertiesByName(loi, loiProperties).forEach(v => row.push(quote(v)));
+  const data = submission.taskData;
   // Header: One column for each task
-  tasks.forEach((task, taskId) => row.push(getValue(taskId, task, data)));
+  tasks.forEach((task, taskId) =>
+    row.push(quote(getValue(taskId, task, data)))
+  );
   // Header: contributor_username, contributor_email
-  const contributor = submission['created']
-    ? (submission['created'] as Dict)['user']
-    : [];
-  row.push(contributor['displayName'] || '');
-  row.push(contributor['email'] || '');
+  row.push(quote(submission.created?.displayName));
+  row.push(quote(submission.created?.emailAddress));
   csvStream.write(row);
 }
 
-/**
- * Returns the WKT string converted from the given geometry object
- *
- * @param geometryObject - the GeoJSON geometry object extracted from the LOI. This should have format:
- *   {
- *      coordinates: any[],
- *      type: string
- *   }
- * @returns The WKT string version of the object
- * https://www.vertica.com/docs/9.3.x/HTML/Content/Authoring/AnalyzingData/Geospatial/Spatial_Definitions/WellknownTextWKT.htm
- *
- * @beta
- */
-function toWkt(geometryObject: any): string {
-  return geojsonToWKT(Datastore.fromFirestoreMap(geometryObject));
+function quote(value: any): string {
+  if (value == null) {
+    return '';
+  }
+  if (typeof value === 'number') {
+    return value.toString();
+  }
+  const escaped = value.toString().replaceAll('"', '""');
+  return `"${escaped}"`;
+}
+
+function toWkt(geometry: Pb.IGeometry): string {
+  return geojsonToWKT(toGeoJsonGeometry(geometry));
 }
 
 /**
- * Returns the string representation of a specific task element result.
+ * Returns the string or number representation of a specific task element result.
  */
-function getValue(taskId: string, task: Task, data: any) {
-  const result = data[taskId] || '';
-  if (
-    task.type === 'multiple_choice' &&
-    Array.isArray(result) &&
-    task.options
-  ) {
-    return result.map(id => getMultipleChoiceValues(id, task)).join(', ');
-  } else if (task.type === 'capture_location') {
-    if (!result) {
-      return '';
-    }
-    return toWkt(result.geometry || result);
+function getValue(
+  taskId: string,
+  task: Task,
+  data: Pb.ITaskData[]
+): string | number | null {
+  const result = data.find(d => d.taskId === taskId);
+  if (!result) {
+    return null;
+  }
+  if (result.textResponse) {
+    return result.textResponse.text ?? null;
+  } else if (result.numberResponse) {
+    return getNumberValue(result.numberResponse);
+  } else if (result.dateTimeResponse) {
+    return getDateTimeValue(result.dateTimeResponse);
+  } else if (result.multipleChoiceResponses) {
+    return getMultipleChoiceValues(task, result.multipleChoiceResponses);
+  } else if (result.captureLocationResult) {
+    // TODO(#1916): Include altitude and accuracy in separate columns.
+    return toWkt(
+      new Pb.Geometry({
+        point: new Pb.Point({
+          coordinates: result.captureLocationResult.coordinates,
+        }),
+      })
+    );
+  } else if (result.drawGeometryResult?.geometry) {
+    // TODO(#1248): Test when implementing other plot annotations feature.
+    return toWkt(result.drawGeometryResult.geometry);
+  } else if (result.takePhotoResult) {
+    return getPhotoUrlValue(result.takePhotoResult);
   } else {
-    return result;
+    return null;
+  }
+}
+
+function getNumberValue(response: Pb.TaskData.INumberResponse): number | null {
+  return response.number ?? null;
+}
+
+function getDateTimeValue(
+  response: Pb.TaskData.IDateTimeResponse
+): string | null {
+  const seconds = response.dateTime?.seconds;
+  if (seconds == null) {
+    return null;
   }
+  return new Date(Number(seconds) * 1000).toISOString();
 }
 
 /**
- * Returns the code associated with a specified multiple choice option, or if
- * the code is not defined, returns the label in English.
+ * Returns a comma-separated list of the labels of the
+ * specified multiple choice option, or the raw text if "Other".
  */
-function getMultipleChoiceValues(id: any, task: Task) {
-  // "Other" options are encoded to be surrounded by square brakets, to let us
-  // distinguish them from the other pre-defined options.
-  if (isOtherOption(id)) {
-    return extractOtherOption(id);
-  }
-  const options = task.options || {};
-  const option = options[id] || {};
-  const label = option.label || {};
-  // TODO: i18n.
-  return option.code || label || '';
+function getMultipleChoiceValues(
+  task: Task,
+  responses: Pb.TaskData.IMultipleChoiceResponses
+) {
+  const values =
+    responses.selectedOptionIds?.map(
+      id => getMultipleChoiceLabel(task, id) || '#ERR'
+    ) || [];
+  if (responses.otherText && responses.otherText.trim() !== '')
+    values.push(responses.otherText);
+  return values.join(',');
 }
 
-function isOtherOption(submission: any): boolean {
-  // "Other" options are encoded to be surrounded by square brakets, to let us
-  // distinguish them from the other pre-defined options.
-  return (
-    typeof submission === 'string' &&
-    submission.startsWith('[ ') &&
-    submission.endsWith(' ]')
-  );
+function getMultipleChoiceLabel(task: Task, id: string): string | null {
+  return task?.options?.find((o: any) => o.id === id)?.label;
 }
 
-function extractOtherOption(submission: string): string {
-  const match = submission.match(/\[(.*?)\]/); // Match any text between []
-  return match ? match[1].trim() : ''; // Extract the match and remove spaces
+function getPhotoUrlValue(result: Pb.TaskData.ITakePhotoResult): string | null {
+  return result?.photoPath || null;
 }
 
 /**
  * Returns the file name in lowercase (replacing any special characters with '-') for csv export
  */
-function getFileName(jobName: string) {
+function getFileName(jobName: string | null) {
   jobName = jobName || 'ground-export';
   const fileBase = jobName.toLowerCase().replace(/[^a-z0-9]/gi, '-');
   return `${fileBase}.csv`;
@@ -253,22 +303,16 @@ function getFileName(jobName: string) {
 
 function getPropertyNames(lois: QuerySnapshot<DocumentData>): Set<string> {
   return new Set(
-    lois.docs
-      .map(loi =>
-        Object.keys(loi.get('properties') || {})
-          // Don't retrieve ID because we already store it in a separate column
-          .filter(prop => prop !== 'id')
-      )
-      .flat()
+    lois.docs.flatMap(loi => Object.keys(loi.get(l.properties) || {}))
   );
 }
 
 function getPropertiesByName(
-  loiDoc: LoiDocument,
-  loiProperties: Set<string>
-): List<string> {
+  loi: Pb.LocationOfInterest,
+  properties: Set<string | number>
+): List<string | number | null> {
   // Fill the list with the value associated with a prop, if the LOI has it, otherwise leave empty.
-  return List.of(...loiProperties).map(
-    prop => (loiDoc.get('properties') || {})[prop] || ''
-  );
+  return List.of(...properties)
+    .map(prop => loi.properties[prop])
+    .map(value => value?.stringValue || value?.numericValue || null);
 }
diff --git a/functions/src/import-geojson.spec.ts b/functions/src/import-geojson.spec.ts
index e466b669f..5a86e8c06 100644
--- a/functions/src/import-geojson.spec.ts
+++ b/functions/src/import-geojson.spec.ts
@@ -235,7 +235,7 @@ describe('importGeoJson()', () => {
       input: geoJsonWithMultiPolygon,
       expected: [multiPolygonLoi],
     },
-  ];
+  ].filter((_, idx) => idx === 0);
 
   beforeEach(() => {
     mockFirestore = createMockFirestore();
diff --git a/functions/src/testing/http-test-helpers.ts b/functions/src/testing/http-test-helpers.ts
index 86ece4219..c439a9ddc 100644
--- a/functions/src/testing/http-test-helpers.ts
+++ b/functions/src/testing/http-test-helpers.ts
@@ -31,13 +31,33 @@ export async function createPostRequestSpy(
   });
 }
 
-export function createResponseSpy(): functions.Response<any> {
+export async function createGetRequestSpy(
+  args: object
+): Promise<functions.https.Request> {
+  return jasmine.createSpyObj<functions.https.Request>('request', ['unpipe'], {
+    ...args,
+    method: 'GET',
+  });
+}
+
+export function createResponseSpy(chunks?: string[]): functions.Response<any> {
   const res = jasmine.createSpyObj<functions.Response<any>>('response', [
     'send',
     'status',
     'end',
+    'write',
+    'type',
+    'setHeader',
+    'on',
+    'once',
+    'emit',
+    'write',
   ]);
-  res.status.and.returnValue(res);
-  res.end.and.returnValue(res);
+  res.status.and.callThrough().and.returnValue(res);
+  res.end.and.callThrough().and.returnValue(res);
+  res.write.and.callFake((chunk: any): boolean => {
+    chunks?.push(chunk.toString());
+    return true;
+  });
   return res;
 }
diff --git a/lib/.eslintrc.json b/lib/.eslintrc.json
index 8b418906e..7831e6ca7 100644
--- a/lib/.eslintrc.json
+++ b/lib/.eslintrc.json
@@ -7,5 +7,8 @@
   "parser": "@typescript-eslint/parser",
   "plugins": [
     "eslint-plugin-absolute-imports"
-  ]
+  ],
+  "rules": {
+    "eqeqeq": ["error", "always", {"null": "ignore"}]    
+  }
 }
diff --git a/lib/package.json b/lib/package.json
index c166d34ec..f4f156891 100644
--- a/lib/package.json
+++ b/lib/package.json
@@ -3,7 +3,9 @@
   "version": "0.0.1",
   "main": "dist/index.js",
   "description": "Ground shared lib",
-  "files": ["dist"],
+  "files": [
+    "dist"
+  ],
   "scripts": {
     "clean": "rm -rf dist *.log src/generated",
     "lint": "eslint --ext .js,.ts src/",
@@ -29,6 +31,7 @@
     "protobufjs": "^7.3.0"
   },
   "devDependencies": {
+    "@types/geojson": "^7946.0.14",
     "@types/jasmine": "^4.3.5",
     "@types/source-map-support": "^0.5.10",
     "@typescript-eslint/eslint-plugin": "^5.39.0",
diff --git a/lib/src/firestore-to-proto.spec.ts b/lib/src/firestore-to-proto.spec.ts
index 8923e16aa..5d1466728 100644
--- a/lib/src/firestore-to-proto.spec.ts
+++ b/lib/src/firestore-to-proto.spec.ts
@@ -18,7 +18,7 @@ import {GroundProtos} from '@ground/proto';
 import {toMessage} from './firestore-to-proto';
 import {Constructor} from 'protobufjs';
 
-const {Coordinates, Job, LinearRing, Role, Style, Survey, Task} =
+const {Coordinates, Job, LinearRing, Role, Style, Survey, Task, LocationOfInterest} =
   GroundProtos.ground.v1beta1;
 
 describe('toMessage()', () => {
@@ -75,6 +75,21 @@ describe('toMessage()', () => {
         },
       }),
     },
+    {
+      desc: 'converts map<string, Message>',
+      input: {
+        '10': {
+          'stringProperty': {'1': 'aaa'},
+          'numberProperty': {'2': 123.4},
+        },
+      },
+      expected: new LocationOfInterest({
+        properties: {
+          'stringProperty': new LocationOfInterest.Property({stringValue: 'aaa'}),
+          'numberProperty': new LocationOfInterest.Property({numericValue: 123.4}),
+        },
+      }),
+    },
     {
       desc: 'converts enum value',
       input: {
diff --git a/lib/src/geo-json.ts b/lib/src/geo-json.ts
new file mode 100644
index 000000000..f555ab65b
--- /dev/null
+++ b/lib/src/geo-json.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 {GroundProtos} from '@ground/proto';
+import {Geometry, MultiPolygon, Point, Polygon, Position} from 'geojson';
+
+import Pb = GroundProtos.ground.v1beta1;
+
+/**
+ * Returns the equivalent GeoJSON Geometry for the provided Geometry proto.
+ */
+export function toGeoJsonGeometry(pb: Pb.IGeometry): Geometry {
+  if (pb.point) {
+    return toGeoJsonPoint(pb.point);
+  } else if (pb.polygon) {
+    return toGeoJsonPolygon(pb.polygon);
+  } else if (pb.multiPolygon) {
+    return toGeoJsonMultiPolygon(pb.multiPolygon);
+  } else {
+    throw new Error('Unsupported or missing geometry');
+  }
+}
+
+function toGeoJsonPoint(pb: Pb.IPoint): Point {
+  if (!pb.coordinates) throw new Error('Invalid Point: coordinates missing');
+  return {
+    type: 'Point',
+    coordinates: toGeoJsonPosition(pb.coordinates),
+  };
+}
+
+function toGeoJsonPosition(coordinates: Pb.ICoordinates): Position {
+  if (!coordinates.longitude || !coordinates.latitude)
+    throw new Error('Invalid Point: coordinates missing');
+  return [coordinates.longitude, coordinates.latitude];
+}
+
+function toGeoJsonPolygon(pb: Pb.IPolygon): Polygon {
+  return {
+    type: 'Polygon',
+    coordinates: toGeoJsonPolygonCoordinates(pb),
+  };
+}
+
+function toGeoJsonPolygonCoordinates(pb: Pb.IPolygon): Position[][] {
+  if (!pb.shell) throw new Error('Invalid Polygon: shell coordinates missing');
+  return [
+    toGeoJsonPositionArray(pb.shell),
+    ...(pb.holes || []).map(h => toGeoJsonPositionArray(h)),
+  ];
+}
+
+function toGeoJsonPositionArray(ring: Pb.ILinearRing): Position[] {
+  if (!ring.coordinates)
+    throw new Error('Invalid LinearRing: coordinates missing');
+  return ring.coordinates.map(c => toGeoJsonPosition(c));
+}
+
+function toGeoJsonMultiPolygon(multiPolygon: Pb.IMultiPolygon): MultiPolygon {
+  if (!multiPolygon.polygons)
+    throw new Error('Invalid multi-polygon: coordinates missing');
+  return {
+    type: 'MultiPolygon',
+    coordinates: multiPolygon.polygons.map(p => toGeoJsonPolygonCoordinates(p)),
+  };
+}
diff --git a/lib/src/index.ts b/lib/src/index.ts
index 13e650b63..20f37fafd 100644
--- a/lib/src/index.ts
+++ b/lib/src/index.ts
@@ -17,4 +17,5 @@
 export {toDocumentData} from './proto-to-firestore';
 export {toMessage} from './firestore-to-proto';
 export {deleteEmpty, isEmpty} from './obj-util';
+export {toGeoJsonGeometry} from './geo-json';
 export {registry} from './message-registry';
diff --git a/package-lock.json b/package-lock.json
index 59baab7b3..c31e37ea5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -100,6 +100,7 @@
         "protobufjs": "^7.3.0"
       },
       "devDependencies": {
+        "@types/geojson": "^7946.0.14",
         "@types/jasmine": "^4.3.5",
         "@types/source-map-support": "^0.5.10",
         "@typescript-eslint/eslint-plugin": "^5.39.0",
@@ -9244,8 +9245,9 @@
     },
     "node_modules/@types/geojson": {
       "version": "7946.0.14",
-      "dev": true,
-      "license": "MIT"
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz",
+      "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==",
+      "dev": true
     },
     "node_modules/@types/glob": {
       "version": "8.1.0",
diff --git a/proto/src/ground/v1beta1/audit_info.proto b/proto/src/ground/v1beta1/audit_info.proto
index 4d893b542..8ad1fc8af 100644
--- a/proto/src/ground/v1beta1/audit_info.proto
+++ b/proto/src/ground/v1beta1/audit_info.proto
@@ -23,8 +23,7 @@ import "google/protobuf/timestamp.proto";
 option java_multiple_files = true;
 option java_package = "com.google.android.ground.proto";
 
-// Audit info about *who* performed a particular action and *when*. User email
-// addresses are omitted for privacy reasons.
+// Audit info about *who* performed a particular action and *when*.
 message AuditInfo {
   // Required. The id of the user performing the action.
   string user_id = 1;
@@ -40,4 +39,7 @@ message AuditInfo {
 
   // URL of the user's profile picture.
   string photo_url = 5;
+
+  // The user's email address.
+  string email_address = 6;
 }
diff --git a/proto/src/ground/v1beta1/submission.proto b/proto/src/ground/v1beta1/submission.proto
index 22eb9c6cf..e10cea76f 100644
--- a/proto/src/ground/v1beta1/submission.proto
+++ b/proto/src/ground/v1beta1/submission.proto
@@ -102,7 +102,7 @@ message TaskData {
   // A manually entered number response.
   message NumberResponse {
     // The number provided by the user.
-    double number = 2;
+    double number = 1;
   }
 
   // A manually selected date and/or time response.
diff --git a/web/.eslintrc.json b/web/.eslintrc.json
index 6dfa0738a..f8da9a32f 100644
--- a/web/.eslintrc.json
+++ b/web/.eslintrc.json
@@ -8,6 +8,7 @@
     "jasmine": true
   },
   "rules": {
+    "eqeqeq": ["error", "always", {"null": "ignore"}],  
     "node/no-unpublished-import": "off",
     "node/no-unpublished-require": "off",
     "@typescript-eslint/no-unused-vars": ["off", { "varsIgnorePattern": "_.*" }],