Repository: cityofaustin/atd-moped
Pull request #1596: Update Moped database to store eCapris subproject statuses
Author: login: mddilley
Created: 2025-05-09T23:15:34Z | Merged: 2025-05-22T17:06:13Z

## Associated issues
Closes https://github.com/cityofaustin/atd-data-tech/issues/21791

This is the proposed model for the eCapris subproject statuses which takes into account several important details about these statuses and the current way of associating eCapris subproject IDs with Moped projects. These eCapris statuses will eventually be displayed alongside Moped project internal notes and status updates.

The important points:
- Many Moped projects can be associated with the same eCapris subproject IDs, and this ID is stored in a column in the `moped_project` table.
   - So, duplication of eCapris statuses is a risk that is mitigated by using the unique IDs of synced eCapris records in the `on_conflict` part of the insert mutation. If there is a conflict in this ID, no insert will happen.
- eCapris statuses cannot be edited once they are entered in eCapris
   - So, we do not need to be concerned with updating synced statuses. Once they are in the Moped DB they will be available to current projects with a matching eCapris subproject ID and to projects that are associated with that subproject ID in the future too.
   - The planned ETL will be responsible for pulling existing and future eCapris subproject statuses associated with eCapris subproject IDs that are currently set on any Moped project that has syncing enabled.

**The future**
- If we need to implement a way to track project-specific data that needs to be appended to eCapris statuses, then we can still add another table that tracks it. Maybe something like:
```sql
CREATE TABLE moped_project_ecapris_statuses_metadata (
    moped_project_id INTEGER REFERENCES moped_project(project_id),
    subproject_status_id INTEGER REFERENCES ecapris_subproject_statuses(subproject_status_id),
    is_hidden BOOLEAN DEFAULT FALSE,
    ...
);
```


## Testing
**URL to test:** 
<!---
deploy-preview-[pr-number]--atd-moped-main.netlify.app/moped/
--->
Local

**Steps to test:**
1. Start the local stack from a production snapshot. We will be testing on project 1986 which already has an eCapris subproject of 13229.004 associated with it. Open the Hasura console to test mutations and queries.
2. Let's test sync toggling by change a project `should_sync_ecapris_statuses` column to `false`.
```
mutation ToggleEcaprisStatusesSyncOn {
  update_moped_project(where: {project_id: {_eq: 1986}}, _set: {should_sync_ecapris_statuses: false}) {
    returning {
      project_id
    }
  }
}
```
3. Now, let's pretend to be the ETL and insert some eCapris statuses with a sample payload of real eCapris data - see the dropdown below the mutation to copy the payload. Try this mutation multiple times and see that no additional insert happen once a status is inserted the first time.
```
mutation InsertEcaprisStatuses($objects: [ecapris_subproject_statuses_insert_input!]!) {
  insert_ecapris_subproject_statuses(objects: $objects, on_conflict: {constraint: ecapris_subproject_statuses_subproject_status_id_key, update_columns: []}) {
    returning {
      subproject_status_id
    }
  }
}
```

<details>

<summary>Sample eCapris statuses payload</summary>

### JSON payload 

```json
{
  "objects": [
    {
      "ecapris_subproject_id": "13229.004",
      "subproject_name": "Buffered to Protected Bikeway Projects - Phase 2",
      "subproject_status_id": 287579,
      "current_status_fl": true,
      "sub_project_status_desc": "Design is underway for multiple locations",
      "review_timestamp": "2025-02-21 06:50:40.000",
      "subproject_status_impacts": "None",
      "summary_description": null,
      "reviewed_by_name": "Wilkes, Nathan",
      "reviewed_by_email": "Nathan.Wilkes@austintexas.gov"
    },
    {
      "ecapris_subproject_id": "13229.004",
      "subproject_name": "Buffered to Protected Bikeway Projects - Phase 2",
      "subproject_status_id": 260207,
      "current_status_fl": false,
      "sub_project_status_desc": "Subproject Created in eCAPRIS",
      "review_timestamp": "2022-11-07 00:00:00.000",
      "subproject_status_impacts": null,
      "summary_description": null,
      "reviewed_by_name": "King, Jennifer",
      "reviewed_by_email": "Jenny.King@austintexas.gov"
    },
    {
      "ecapris_subproject_id": "13229.004",
      "subproject_name": "Buffered to Protected Bikeway Projects - Phase 2",
      "subproject_status_id": 264133,
      "current_status_fl": false,
      "sub_project_status_desc": "Various locations citywide to upgrade bikeways with physical protection. Design underway",
      "review_timestamp": "2023-03-15 21:09:56.000",
      "subproject_status_impacts": "None",
      "summary_description": null,
      "reviewed_by_name": "Dierenfield, Laura",
      "reviewed_by_email": "Laura.Dierenfield@austintexas.gov"
    },
    {
      "ecapris_subproject_id": "13229.004",
      "subproject_name": "Buffered to Protected Bikeway Projects - Phase 2",
      "subproject_status_id": 282233,
      "current_status_fl": false,
      "sub_project_status_desc": "Design is underway for multiple locations",
      "review_timestamp": "2024-09-26 16:21:25.000",
      "subproject_status_impacts": "None",
      "summary_description": null,
      "reviewed_by_name": "Wilkes, Nathan",
      "reviewed_by_email": "Nathan.Wilkes@austintexas.gov"
    },
    {
      "ecapris_subproject_id": "13229.004",
      "subproject_name": "Buffered to Protected Bikeway Projects - Phase 2",
      "subproject_status_id": 274574,
      "current_status_fl": false,
      "sub_project_status_desc": "Design is underway for multiple locations",
      "review_timestamp": "2024-03-03 06:09:36.000",
      "subproject_status_impacts": "None",
      "summary_description": null,
      "reviewed_by_name": "Wilkes, Nathan",
      "reviewed_by_email": "Nathan.Wilkes@austintexas.gov"
    }
  ]
}
```

</details>
4. Now, let's query the statuses that have an eCapris subproject ID of 13229.004 just like we would fetch a project's notes. This query has some aliases as an example of how eCapris statuses could map to our internal note/status update schema. Notice that some of the statuses return a matched Moped user while some do not. This is a nice to have, and we could always modify the ETL to continue attempting matches in the future if it becomes important to attempt backfills. Otherwise, the name or email from eCapris is a good fallback.

```
query GetProjectEcaprisStatus {
  ecapris_subproject_statuses(where: {ecapris_subproject_id: {_eq: "13229.004"}}){
    project_note_id: subproject_status_id
    project_note: sub_project_status_desc
    created_at: review_timestamp
    email: reviewed_by_email
    fallback_name: reviewed_by_name
    moped_user {
      first_name
      last_name
    }
    __typename
  }
}
```

---
#### Ship list
- [x] Code reviewed 
- [x] Product manager approved
- [x] Product manager checked [DB view dependencies](https://atd-dts.gitbook.io/moped-documentation/product-management/updating-the-arcgis-online-components-database-view)
- ~[ ] Product manager added to [QA test script](https://docs.google.com/spreadsheets/d/1n_O6MLh9cwwPf57HUM394Ea-z9uuoEV1-QW4axNZXLE/edit#) if applicable~



Diff:
diff --git a/.github/workflows/etl_ecapris_statuses_docker.yml b/.github/workflows/etl_ecapris_statuses_docker.yml
new file mode 100644
index 0000000000..abcb7c5176
--- /dev/null
+++ b/.github/workflows/etl_ecapris_statuses_docker.yml
@@ -0,0 +1,32 @@
+name: "Build eCapris statuses ETL Docker image and push to DockerHub"
+on:
+  push:
+    branches:
+      - main
+      - production
+    paths:
+      - ".github/workflows/etl_ecapris_statuses_docker.yml"
+      - "moped-etl/ecapris-statuses/**"
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+jobs:
+  build:
+    name: Build eCapris status sync ETL docker image
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+      - name: Login to DockerHub
+        uses: docker/login-action@v2
+        with:
+          username: ${{ secrets.DOCKER_USERNAME }}
+          password: ${{ secrets.DOCKER_TOKEN }}
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v2
+      - name: Build and push image
+        uses: docker/build-push-action@v4
+        with:
+          platforms: linux/amd64,linux/arm64
+          push: true
+          tags: atddocker/atd-moped-etl-ecapris-statuses:${{ github.ref_name }}
+          context: ./moped-etl/ecapris-statuses
diff --git a/README.md b/README.md
index 24d6fceac9..4bcd1360c7 100644
--- a/README.md
+++ b/README.md
@@ -105,7 +105,7 @@ Before you begin, make sure you have the following installed on your system:
 - [ArcGIS Moped Database Viewer](https://austin.maps.arcgis.com/apps/webappviewer/index.html?id=404d31d56b57491abe53ccfd718fcaee) - GIS map application that includes Moped Project Data as a layer (requires ArcGIS Online permissions)
 - [API](./moped-api/README.md) - serverless Flask API, handling data operations and API requests
 - [auth](./moped-auth/README.md) - manages authentication using AWS Cognito, including JWT token generation and validation for secure access control
-- [etl](./moped-etl/README.md) - integration scripts for ArcGIS & Knack Data Tracker
+- [etl](./moped-etl/README.md) - integration scripts for ArcGIS, Knack Data Tracker, and eCapris
 - [toolbox](./moped-toolbox/README.md) - collection of utilities and scripts designed for ad-hoc maintenance tasks
 
 ## License
diff --git a/moped-database/metadata/databases/default/tables/public_ecapris_subproject_statuses.yaml b/moped-database/metadata/databases/default/tables/public_ecapris_subproject_statuses.yaml
new file mode 100644
index 0000000000..96bae9710a
--- /dev/null
+++ b/moped-database/metadata/databases/default/tables/public_ecapris_subproject_statuses.yaml
@@ -0,0 +1,68 @@
+table:
+  name: ecapris_subproject_statuses
+  schema: public
+object_relationships:
+  - name: moped_user
+    using:
+      foreign_key_constraint_on: created_by_user_id
+select_permissions:
+  - role: moped-admin
+    permission:
+      columns:
+        - sub_project_status_desc
+        - ecapris_subproject_id
+        - reviewed_by_email
+        - reviewed_by_name
+        - created_by_user_id
+        - updated_by_user_id
+        - current_status_fl
+        - summary_description
+        - subproject_name
+        - id
+        - review_timestamp
+        - created_at
+        - updated_at
+        - subproject_status_id
+        - subproject_status_impacts
+      filter: {}
+    comment: ""
+  - role: moped-editor
+    permission:
+      columns:
+        - sub_project_status_desc
+        - ecapris_subproject_id
+        - reviewed_by_email
+        - reviewed_by_name
+        - created_by_user_id
+        - updated_by_user_id
+        - current_status_fl
+        - summary_description
+        - subproject_name
+        - id
+        - review_timestamp
+        - created_at
+        - updated_at
+        - subproject_status_id
+        - subproject_status_impacts
+      filter: {}
+    comment: ""
+  - role: moped-viewer
+    permission:
+      columns:
+        - sub_project_status_desc
+        - ecapris_subproject_id
+        - reviewed_by_email
+        - reviewed_by_name
+        - created_by_user_id
+        - updated_by_user_id
+        - current_status_fl
+        - summary_description
+        - subproject_name
+        - id
+        - review_timestamp
+        - created_at
+        - updated_at
+        - subproject_status_id
+        - subproject_status_impacts
+      filter: {}
+    comment: ""
diff --git a/moped-database/metadata/databases/default/tables/public_moped_project.yaml b/moped-database/metadata/databases/default/tables/public_moped_project.yaml
index dda029fe01..df5ca75e22 100644
--- a/moped-database/metadata/databases/default/tables/public_moped_project.yaml
+++ b/moped-database/metadata/databases/default/tables/public_moped_project.yaml
@@ -185,6 +185,7 @@ select_permissions:
         - project_sponsor
         - project_website
         - public_process_status_id
+        - should_sync_ecapris_statuses
         - updated_at
       filter: {}
       allow_aggregations: true
@@ -207,6 +208,7 @@ select_permissions:
         - project_sponsor
         - project_website
         - public_process_status_id
+        - should_sync_ecapris_statuses
         - updated_at
       filter: {}
       allow_aggregations: true
@@ -229,6 +231,7 @@ select_permissions:
         - project_sponsor
         - project_website
         - public_process_status_id
+        - should_sync_ecapris_statuses
         - updated_at
       filter: {}
       allow_aggregations: true
@@ -250,6 +253,7 @@ update_permissions:
         - project_sponsor
         - project_website
         - public_process_status_id
+        - should_sync_ecapris_statuses
         - updated_at
       filter: {}
       check: {}
@@ -272,6 +276,7 @@ update_permissions:
         - project_sponsor
         - project_website
         - public_process_status_id
+        - should_sync_ecapris_statuses
         - updated_at
       filter: {}
       check: {}
@@ -285,7 +290,6 @@ event_triggers:
         columns: '*'
       update:
         columns:
-          - ecapris_subproject_id
           - project_id
           - date_added
           - added_by
@@ -294,11 +298,13 @@ event_triggers:
           - parent_project_id
           - interim_project_id
           - project_lead_id
+          - public_process_status_id
+          - should_sync_ecapris_statuses
           - project_name
           - project_description
+          - ecapris_subproject_id
           - project_website
           - knack_project_id
-          - public_process_status_id
           - project_name_secondary
     retry_conf:
       interval_sec: 10
diff --git a/moped-database/metadata/databases/default/tables/tables.yaml b/moped-database/metadata/databases/default/tables/tables.yaml
index 9a6771ed42..dc9ccfa57b 100644
--- a/moped-database/metadata/databases/default/tables/tables.yaml
+++ b/moped-database/metadata/databases/default/tables/tables.yaml
@@ -2,6 +2,7 @@
 - "!include deprecated_moped_types.yaml"
 - "!include public_component_arcgis_online_view.yaml"
 - "!include public_current_phase_view.yaml"
+- "!include public_ecapris_subproject_statuses.yaml"
 - "!include public_exploded_component_arcgis_online_view.yaml"
 - "!include public_feature_drawn_lines.yaml"
 - "!include public_feature_drawn_points.yaml"
diff --git a/moped-database/migrations/default/1746640451691_ecapris_status_updates/down.sql b/moped-database/migrations/default/1746640451691_ecapris_status_updates/down.sql
new file mode 100644
index 0000000000..3915821843
--- /dev/null
+++ b/moped-database/migrations/default/1746640451691_ecapris_status_updates/down.sql
@@ -0,0 +1,9 @@
+-- Remove should_sync_ecapris_statuses column from moped_project table 
+ALTER TABLE moped_project
+DROP COLUMN should_sync_ecapris_statuses;
+
+-- Drop ecapris_status_updates table
+DROP TABLE IF EXISTS public.ecapris_subproject_statuses;
+
+-- Drop function to match user email and set created_by_user_id
+DROP FUNCTION IF EXISTS public.find_user_match_by_email;
diff --git a/moped-database/migrations/default/1746640451691_ecapris_status_updates/up.sql b/moped-database/migrations/default/1746640451691_ecapris_status_updates/up.sql
new file mode 100644
index 0000000000..90d68e0bfb
--- /dev/null
+++ b/moped-database/migrations/default/1746640451691_ecapris_status_updates/up.sql
@@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT TRUE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    ecapris_subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    reviewed_by_email TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    created_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+    updated_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE public.ecapris_subproject_statuses IS 'Stores eCAPRIS subproject status records synced from the FSD Data Warehouse to supplement the moped_proj_notes table records.';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.id IS 'Primary key for the table';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.ecapris_subproject_id IS 'eCapris subproject ID number';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_name IS 'Name of eCapris subproject';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_id IS 'Unique ID of subproject status from eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.current_status_fl IS 'Is this the current and most recent status?';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.sub_project_status_desc IS 'Content of the subproject status';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.review_timestamp IS 'Timestamp of the status update - MM/DD/YYYY HH:MM:SS format';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_impacts IS 'Updates on project blockers';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.summary_description IS 'More of a public-ready status could be used down the road; nullable in eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.reviewed_by_name IS 'First and last name of author; nullable in eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.reviewed_by_email IS 'Email of author';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.created_at IS 'Timestamp when the record was created';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.updated_at IS 'Timestamp when the record was last updated';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.created_by_user_id IS 'ID of the user who created the record';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.updated_by_user_id IS 'ID of the user who updated the record';
+
+-- Create trigger to set updated_at audit column before update
+CREATE TRIGGER set_ecapris_subproject_statuses_updated_at BEFORE UPDATE ON public.ecapris_subproject_statuses FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
+
+COMMENT ON TRIGGER set_ecapris_subproject_statuses_updated_at ON public.ecapris_subproject_statuses IS 'Trigger to set updated_at on row update';
+
+-- Create function to search for eCapris subproject status author match by email and set created_by_user_id if found
+CREATE OR REPLACE FUNCTION public.find_ecapris_user_match_by_email()
+RETURNS TRIGGER AS $$
+BEGIN
+    -- Try to find matching user by email (case-insensitive) email 
+    NEW.created_by_user_id := (
+        SELECT user_id 
+        FROM moped_users 
+        WHERE LOWER(email) = LOWER(NEW.reviewed_by_email)
+        LIMIT 1
+    );
+    
+    RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION public.find_ecapris_user_match_by_email() IS 'Function to attempt matching eCapris status author email to Moped users by email';
+
+-- Create trigger to call function to try match author (if there is one) before insert
+CREATE TRIGGER find_ecapris_user_match_by_email BEFORE INSERT ON public.ecapris_subproject_statuses
+FOR EACH ROW EXECUTE FUNCTION public.find_ecapris_user_match_by_email();
+
+COMMENT ON TRIGGER find_ecapris_user_match_by_email ON public.ecapris_subproject_statuses IS 'Trigger to attempt match of eCapris author to Moped user by email on insert';
diff --git a/moped-database/views/component_arcgis_online_view.sql b/moped-database/views/component_arcgis_online_view.sql
index 8dbaab87fa..d2d19b4745 100644
--- a/moped-database/views/component_arcgis_online_view.sql
+++ b/moped-database/views/component_arcgis_online_view.sql
@@ -1,118 +1,150 @@
 -- Most recent migration: moped-database/migrations/default/1739832264647_agol_view_is_mapped_bool/up.sql
 
-CREATE OR REPLACE VIEW component_arcgis_online_view AS  WITH work_types AS (
-         SELECT mpcwt.project_component_id,
-            string_agg(mwt.name, ', '::text) AS work_types
-           FROM moped_proj_component_work_types mpcwt
-             LEFT JOIN moped_work_types mwt ON mpcwt.work_type_id = mwt.id
-          WHERE mpcwt.is_deleted = false
-          GROUP BY mpcwt.project_component_id
-        ), council_districts AS (
-         SELECT features.component_id AS project_component_id,
-            string_agg(DISTINCT features_council_districts.council_district_id::text, ', '::text) AS council_districts,
-            string_agg(DISTINCT lpad(features_council_districts.council_district_id::text, 2, '0'::text), ', '::text) AS council_districts_searchable
-           FROM features_council_districts
-             LEFT JOIN features ON features_council_districts.feature_id = features.id
-          WHERE features.is_deleted = false
-          GROUP BY features.component_id
-        ), comp_geography AS (
-         SELECT feature_union.component_id AS project_component_id,
-            string_agg(DISTINCT feature_union.id::text, ', '::text) AS feature_ids,
-            st_asgeojson(st_multi(st_union(array_agg(feature_union.geography))))::json AS geometry,
-            st_asgeojson(st_multi(st_union(array_agg(feature_union.line_geography))))::json AS line_geometry,
-            string_agg(DISTINCT feature_union.signal_id::text, ', '::text) AS signal_ids,
-            sum(feature_union.length_feet) AS length_feet_total
-           FROM ( SELECT feature_signals.id,
-                    feature_signals.component_id,
-                    feature_signals.geography::geometry AS geography,
-                    st_exteriorring(st_buffer(feature_signals.geography, 7::double precision)::geometry) AS line_geography,
-                    feature_signals.signal_id,
-                    NULL::integer AS length_feet
-                   FROM feature_signals
-                  WHERE feature_signals.is_deleted = false
-                UNION ALL
-                 SELECT feature_street_segments.id,
-                    feature_street_segments.component_id,
-                    feature_street_segments.geography::geometry AS geography,
-                    feature_street_segments.geography::geometry AS line_geography,
-                    NULL::integer AS signal_id,
-                    feature_street_segments.length_feet
-                   FROM feature_street_segments
-                  WHERE feature_street_segments.is_deleted = false
-                UNION ALL
-                 SELECT feature_intersections.id,
-                    feature_intersections.component_id,
-                    feature_intersections.geography::geometry AS geography,
-                    st_exteriorring(st_buffer(feature_intersections.geography, 7::double precision)::geometry) AS line_geography,
-                    NULL::integer AS signal_id,
-                    NULL::integer AS length_feet
-                   FROM feature_intersections
-                  WHERE feature_intersections.is_deleted = false
-                UNION ALL
-                 SELECT feature_drawn_points.id,
-                    feature_drawn_points.component_id,
-                    feature_drawn_points.geography::geometry AS geography,
-                    st_exteriorring(st_buffer(feature_drawn_points.geography, 7::double precision)::geometry) AS line_geography,
-                    NULL::integer AS signal_id,
-                    NULL::integer AS length_feet
-                   FROM feature_drawn_points
-                  WHERE feature_drawn_points.is_deleted = false
-                UNION ALL
-                 SELECT feature_drawn_lines.id,
-                    feature_drawn_lines.component_id,
-                    feature_drawn_lines.geography::geometry AS geography,
-                    feature_drawn_lines.geography::geometry AS line_geography,
-                    NULL::integer AS signal_id,
-                    feature_drawn_lines.length_feet
-                   FROM feature_drawn_lines
-                  WHERE feature_drawn_lines.is_deleted = false
-                UNION ALL
-                 SELECT feature_school_beacons.id,
-                    feature_school_beacons.component_id,
-                    feature_school_beacons.geography::geometry AS geography,
-                    st_exteriorring(st_buffer(feature_school_beacons.geography, 7::double precision)::geometry) AS line_geography,
-                    NULL::integer AS signal_id,
-                    NULL::integer AS length_feet
-                   FROM feature_school_beacons
-                  WHERE feature_school_beacons.is_deleted = false) feature_union
-          GROUP BY feature_union.component_id
-        ), subcomponents AS (
-         SELECT mpcs.project_component_id,
-            string_agg(ms.subcomponent_name, ', '::text) AS subcomponents
-           FROM moped_proj_components_subcomponents mpcs
-             LEFT JOIN moped_subcomponents ms ON mpcs.subcomponent_id = ms.subcomponent_id
-          WHERE mpcs.is_deleted = false
-          GROUP BY mpcs.project_component_id
-        ), component_tags AS (
-         SELECT mpct.project_component_id,
-            string_agg((mct.type || ' - '::text) || mct.name, ', '::text) AS component_tags
-           FROM moped_proj_component_tags mpct
-             LEFT JOIN moped_component_tags mct ON mpct.component_tag_id = mct.id
-          WHERE mpct.is_deleted = false
-          GROUP BY mpct.project_component_id
-        ), related_projects AS (
-         SELECT pmp.project_id,
-            concat_ws(', '::text, pmp.project_id, string_agg(cmp.project_id::text, ', '::text)) AS related_project_ids_with_self,
-            concat_ws(', '::text, lpad(pmp.project_id::text, 5, '0'::text), string_agg(lpad(cmp.project_id::text, 5, '0'::text), ', '::text)) AS related_project_ids_searchable_with_self
-           FROM moped_project pmp
-             LEFT JOIN moped_project cmp ON pmp.project_id = cmp.parent_project_id
-          WHERE cmp.is_deleted = false
-          GROUP BY pmp.project_id
-        ), latest_public_meeting_date AS (
-         SELECT mpm.project_id,
-            COALESCE(max(mpm.date_actual), max(mpm.date_estimate)) AS latest
-           FROM moped_proj_milestones mpm
-          WHERE mpm.milestone_id = 65 AND mpm.is_deleted = false
-          GROUP BY mpm.project_id
-        ), earliest_active_or_construction_phase_date AS (
-         SELECT mpp.project_id,
-            min(mpp.phase_start) AS earliest
-           FROM moped_proj_phases mpp
-             LEFT JOIN moped_phases mp ON mpp.phase_id = mp.phase_id
-          WHERE (mp.phase_name_simple = ANY (ARRAY['Active'::text, 'Construction'::text])) AND mpp.is_deleted = false
-          GROUP BY mpp.project_id
-        )
- SELECT mpc.project_id,
+CREATE OR REPLACE VIEW component_arcgis_online_view AS WITH work_types AS (
+    SELECT
+        mpcwt.project_component_id,
+        string_agg(mwt.name, ', '::text) AS work_types
+    FROM moped_proj_component_work_types mpcwt
+    LEFT JOIN moped_work_types mwt ON mpcwt.work_type_id = mwt.id
+    WHERE mpcwt.is_deleted = false
+    GROUP BY mpcwt.project_component_id
+),
+
+council_districts AS (
+    SELECT
+        features.component_id AS project_component_id,
+        string_agg(DISTINCT features_council_districts.council_district_id::text, ', '::text) AS council_districts,
+        string_agg(DISTINCT lpad(features_council_districts.council_district_id::text, 2, '0'::text), ', '::text) AS council_districts_searchable
+    FROM features_council_districts
+    LEFT JOIN features ON features_council_districts.feature_id = features.id
+    WHERE features.is_deleted = false
+    GROUP BY features.component_id
+),
+
+comp_geography AS (
+    SELECT
+        feature_union.component_id AS project_component_id,
+        string_agg(DISTINCT feature_union.id::text, ', '::text) AS feature_ids,
+        st_asgeojson(st_multi(st_union(array_agg(feature_union.geography))))::json AS geometry,
+        st_asgeojson(st_multi(st_union(array_agg(feature_union.line_geography))))::json AS line_geometry,
+        string_agg(DISTINCT feature_union.signal_id::text, ', '::text) AS signal_ids,
+        sum(feature_union.length_feet) AS length_feet_total
+    FROM (
+        SELECT
+            feature_signals.id,
+            feature_signals.component_id,
+            feature_signals.geography::geometry AS geography,
+            st_exteriorring(st_buffer(feature_signals.geography, 7::double precision)::geometry) AS line_geography,
+            feature_signals.signal_id,
+            null::integer AS length_feet
+        FROM feature_signals
+        WHERE feature_signals.is_deleted = false
+        UNION ALL
+        SELECT
+            feature_street_segments.id,
+            feature_street_segments.component_id,
+            feature_street_segments.geography::geometry AS geography,
+            feature_street_segments.geography::geometry AS line_geography,
+            null::integer AS signal_id,
+            feature_street_segments.length_feet
+        FROM feature_street_segments
+        WHERE feature_street_segments.is_deleted = false
+        UNION ALL
+        SELECT
+            feature_intersections.id,
+            feature_intersections.component_id,
+            feature_intersections.geography::geometry AS geography,
+            st_exteriorring(st_buffer(feature_intersections.geography, 7::double precision)::geometry) AS line_geography,
+            null::integer AS signal_id,
+            null::integer AS length_feet
+        FROM feature_intersections
+        WHERE feature_intersections.is_deleted = false
+        UNION ALL
+        SELECT
+            feature_drawn_points.id,
+            feature_drawn_points.component_id,
+            feature_drawn_points.geography::geometry AS geography,
+            st_exteriorring(st_buffer(feature_drawn_points.geography, 7::double precision)::geometry) AS line_geography,
+            null::integer AS signal_id,
+            null::integer AS length_feet
+        FROM feature_drawn_points
+        WHERE feature_drawn_points.is_deleted = false
+        UNION ALL
+        SELECT
+            feature_drawn_lines.id,
+            feature_drawn_lines.component_id,
+            feature_drawn_lines.geography::geometry AS geography,
+            feature_drawn_lines.geography::geometry AS line_geography,
+            null::integer AS signal_id,
+            feature_drawn_lines.length_feet
+        FROM feature_drawn_lines
+        WHERE feature_drawn_lines.is_deleted = false
+        UNION ALL
+        SELECT
+            feature_school_beacons.id,
+            feature_school_beacons.component_id,
+            feature_school_beacons.geography::geometry AS geography,
+            st_exteriorring(st_buffer(feature_school_beacons.geography, 7::double precision)::geometry) AS line_geography,
+            null::integer AS signal_id,
+            null::integer AS length_feet
+        FROM feature_school_beacons
+        WHERE feature_school_beacons.is_deleted = false
+    ) feature_union
+    GROUP BY feature_union.component_id
+),
+
+subcomponents AS (
+    SELECT
+        mpcs.project_component_id,
+        string_agg(ms.subcomponent_name, ', '::text) AS subcomponents
+    FROM moped_proj_components_subcomponents mpcs
+    LEFT JOIN moped_subcomponents ms ON mpcs.subcomponent_id = ms.subcomponent_id
+    WHERE mpcs.is_deleted = false
+    GROUP BY mpcs.project_component_id
+),
+
+component_tags AS (
+    SELECT
+        mpct.project_component_id,
+        string_agg((mct.type || ' - '::text) || mct.name, ', '::text) AS component_tags
+    FROM moped_proj_component_tags mpct
+    LEFT JOIN moped_component_tags mct ON mpct.component_tag_id = mct.id
+    WHERE mpct.is_deleted = false
+    GROUP BY mpct.project_component_id
+),
+
+related_projects AS (
+    SELECT
+        pmp.project_id,
+        concat_ws(', '::text, pmp.project_id, string_agg(cmp.project_id::text, ', '::text)) AS related_project_ids_with_self,
+        concat_ws(', '::text, lpad(pmp.project_id::text, 5, '0'::text), string_agg(lpad(cmp.project_id::text, 5, '0'::text), ', '::text)) AS related_project_ids_searchable_with_self
+    FROM moped_project pmp
+    LEFT JOIN moped_project cmp ON pmp.project_id = cmp.parent_project_id
+    WHERE cmp.is_deleted = false
+    GROUP BY pmp.project_id
+),
+
+latest_public_meeting_date AS (
+    SELECT
+        mpm.project_id,
+        coalesce(max(mpm.date_actual), max(mpm.date_estimate)) AS latest
+    FROM moped_proj_milestones mpm
+    WHERE mpm.milestone_id = 65 AND mpm.is_deleted = false
+    GROUP BY mpm.project_id
+),
+
+earliest_active_or_construction_phase_date AS (
+    SELECT
+        mpp.project_id,
+        min(mpp.phase_start) AS earliest
+    FROM moped_proj_phases mpp
+    LEFT JOIN moped_phases mp ON mpp.phase_id = mp.phase_id
+    WHERE (mp.phase_name_simple = any(ARRAY['Active'::text, 'Construction'::text])) AND mpp.is_deleted = false
+    GROUP BY mpp.project_id
+)
+
+SELECT
+    mpc.project_id,
     mpc.project_component_id,
     comp_geography.feature_ids,
     mpc.component_id,
@@ -121,31 +153,31 @@ CREATE OR REPLACE VIEW component_arcgis_online_view AS  WITH work_types AS (
     comp_geography.signal_ids,
     council_districts.council_districts,
     council_districts.council_districts_searchable,
-    NOT COALESCE(council_districts.council_districts IS NULL OR council_districts.council_districts = ''::text, false) AS is_within_city_limits,
+    NOT coalesce(council_districts.council_districts IS null OR council_districts.council_districts = ''::text, false) AS is_within_city_limits,
     comp_geography.length_feet_total,
     round(comp_geography.length_feet_total::numeric / 5280::numeric, 2) AS length_miles_total,
     mc.component_name,
     mc.component_subtype,
     mc.component_name_full,
     'placeholder text'::text AS component_categories,
-        CASE
-            WHEN mc.line_representation = true THEN 'Line'::text
-            ELSE 'Point'::text
-        END AS geometry_type,
-        CASE
-            WHEN comp_geography.geometry IS NULL THEN false
-            ELSE true
-        END AS is_mapped,
+    CASE
+        WHEN mc.line_representation = true THEN 'Line'::text
+        ELSE 'Point'::text
+    END AS geometry_type,
+    CASE
+        WHEN comp_geography.geometry IS null THEN false
+        ELSE true
+    END AS is_mapped,
     subcomponents.subcomponents AS component_subcomponents,
     work_types.work_types AS component_work_types,
     component_tags.component_tags,
     mpc.description AS component_description,
     mpc.interim_project_component_id,
-        CASE
-            WHEN mpc.phase_id IS NULL THEN plv.substantial_completion_date
-            WHEN mpc.phase_id IS NOT NULL AND mpc.completion_date IS NULL THEN NULL::timestamp with time zone
-            ELSE mpc.completion_date
-        END AS substantial_completion_date,
+    CASE
+        WHEN mpc.phase_id IS null THEN plv.substantial_completion_date
+        WHEN mpc.phase_id IS NOT null AND mpc.completion_date IS null THEN null::timestamp with time zone
+        ELSE mpc.completion_date
+    END AS substantial_completion_date,
     plv.substantial_completion_date_estimated,
     mpc.srts_id,
     mpc.location_description AS component_location_description,
@@ -162,8 +194,8 @@ CREATE OR REPLACE VIEW component_arcgis_online_view AS  WITH work_types AS (
     current_phase.phase_id AS project_phase_id,
     current_phase.phase_name AS project_phase_name,
     current_phase.phase_name_simple AS project_phase_name_simple,
-    COALESCE(mph.phase_name, current_phase.phase_name) AS current_phase_name,
-    COALESCE(mph.phase_name_simple, current_phase.phase_name_simple) AS current_phase_name_simple,
+    coalesce(mph.phase_name, current_phase.phase_name) AS current_phase_name,
+    coalesce(mph.phase_name_simple, current_phase.phase_name_simple) AS current_phase_name_simple,
     plv.project_team_members,
     plv.project_sponsor,
     plv.project_lead,
@@ -189,33 +221,33 @@ CREATE OR REPLACE VIEW component_arcgis_online_view AS  WITH work_types AS (
     plv.knack_project_id AS knack_data_tracker_project_record_id,
     plv.project_url,
     (plv.project_url || '?tab=map&project_component_id='::text) || mpc.project_component_id::text AS component_url,
-    get_project_development_status(lpmd.latest::timestamp with time zone, eaocpd.earliest, COALESCE(mpc.completion_date, plv.substantial_completion_date), plv.substantial_completion_date_estimated, COALESCE(mph.phase_name_simple, current_phase.phase_name_simple)) AS project_development_status,
+    get_project_development_status(lpmd.latest::timestamp with time zone, eaocpd.earliest, coalesce(mpc.completion_date, plv.substantial_completion_date), plv.substantial_completion_date_estimated, coalesce(mph.phase_name_simple, current_phase.phase_name_simple)) AS project_development_status,
     project_development_status_date.result AS project_development_status_date,
     to_char(project_development_status_date.result, 'YYYY'::text)::integer AS project_development_status_date_calendar_year,
     to_char(project_development_status_date.result, 'FMMonth YYYY'::text) AS project_development_status_date_calendar_year_month,
     to_char(project_development_status_date.result, 'YYYY-MM'::text) AS project_development_status_date_calendar_year_month_numeric,
     date_part('quarter'::text, project_development_status_date.result)::text AS project_development_status_date_calendar_year_quarter,
-        CASE
-            WHEN date_part('quarter'::text, project_development_status_date.result) = 4::double precision THEN (to_char(project_development_status_date.result, 'YYYY'::text)::integer + 1)::text
-            ELSE to_char(project_development_status_date.result, 'YYYY'::text)
-        END AS project_development_status_date_fiscal_year,
-        CASE
-            WHEN date_part('quarter'::text, project_development_status_date.result) = 4::double precision THEN 1::double precision
-            ELSE date_part('quarter'::text, project_development_status_date.result) + 1::double precision
-        END::text AS project_development_status_date_fiscal_year_quarter,
+    CASE
+        WHEN date_part('quarter'::text, project_development_status_date.result) = 4::double precision THEN (to_char(project_development_status_date.result, 'YYYY'::text)::integer + 1)::text
+        ELSE to_char(project_development_status_date.result, 'YYYY'::text)
+    END AS project_development_status_date_fiscal_year,
+    CASE
+        WHEN date_part('quarter'::text, project_development_status_date.result) = 4::double precision THEN 1::double precision
+        ELSE date_part('quarter'::text, project_development_status_date.result) + 1::double precision
+    END::text AS project_development_status_date_fiscal_year_quarter,
     plv.added_by AS project_added_by
-   FROM moped_proj_components mpc
-     LEFT JOIN comp_geography ON mpc.project_component_id = comp_geography.project_component_id
-     LEFT JOIN council_districts ON mpc.project_component_id = council_districts.project_component_id
-     LEFT JOIN subcomponents ON mpc.project_component_id = subcomponents.project_component_id
-     LEFT JOIN work_types ON mpc.project_component_id = work_types.project_component_id
-     LEFT JOIN component_tags ON mpc.project_component_id = component_tags.project_component_id
-     LEFT JOIN project_list_view plv ON mpc.project_id = plv.project_id
-     LEFT JOIN current_phase_view current_phase ON mpc.project_id = current_phase.project_id
-     LEFT JOIN moped_phases mph ON mpc.phase_id = mph.phase_id
-     LEFT JOIN moped_components mc ON mpc.component_id = mc.component_id
-     LEFT JOIN related_projects rp ON mpc.project_id = rp.project_id
-     LEFT JOIN latest_public_meeting_date lpmd ON mpc.project_id = lpmd.project_id
-     LEFT JOIN earliest_active_or_construction_phase_date eaocpd ON mpc.project_id = eaocpd.project_id
-     LEFT JOIN LATERAL ( SELECT timezone('US/Central'::text, get_project_development_status_date(lpmd.latest::timestamp with time zone, eaocpd.earliest, COALESCE(mpc.completion_date, plv.substantial_completion_date), plv.substantial_completion_date_estimated, COALESCE(mph.phase_name_simple, current_phase.phase_name_simple))) AS result) project_development_status_date ON true
-  WHERE mpc.is_deleted = false AND plv.is_deleted = false;
+FROM moped_proj_components mpc
+LEFT JOIN comp_geography ON mpc.project_component_id = comp_geography.project_component_id
+LEFT JOIN council_districts ON mpc.project_component_id = council_districts.project_component_id
+LEFT JOIN subcomponents ON mpc.project_component_id = subcomponents.project_component_id
+LEFT JOIN work_types ON mpc.project_component_id = work_types.project_component_id
+LEFT JOIN component_tags ON mpc.project_component_id = component_tags.project_component_id
+LEFT JOIN project_list_view plv ON mpc.project_id = plv.project_id
+LEFT JOIN current_phase_view current_phase ON mpc.project_id = current_phase.project_id
+LEFT JOIN moped_phases mph ON mpc.phase_id = mph.phase_id
+LEFT JOIN moped_components mc ON mpc.component_id = mc.component_id
+LEFT JOIN related_projects rp ON mpc.project_id = rp.project_id
+LEFT JOIN latest_public_meeting_date lpmd ON mpc.project_id = lpmd.project_id
+LEFT JOIN earliest_active_or_construction_phase_date eaocpd ON mpc.project_id = eaocpd.project_id
+LEFT JOIN LATERAL (SELECT timezone('US/Central'::text, get_project_development_status_date(lpmd.latest::timestamp with time zone, eaocpd.earliest, coalesce(mpc.completion_date, plv.substantial_completion_date), plv.substantial_completion_date_estimated, coalesce(mph.phase_name_simple, current_phase.phase_name_simple))) AS result) project_development_status_date ON true
+WHERE mpc.is_deleted = false AND plv.is_deleted = false;
diff --git a/moped-database/views/current_phase_view.sql b/moped-database/views/current_phase_view.sql
index 5f0482b49e..6e70e773e5 100644
--- a/moped-database/views/current_phase_view.sql
+++ b/moped-database/views/current_phase_view.sql
@@ -1,11 +1,12 @@
 -- Most recent migration: moped-database/migrations/default/1742416187184_update_current_phase_view_drop_distinct_on/up.sql
 
-CREATE OR REPLACE VIEW current_phase_view AS  SELECT mpp.project_id,
+CREATE OR REPLACE VIEW current_phase_view AS SELECT
+    mpp.project_id,
     mpp.project_phase_id,
     mpp.phase_id,
     mp.phase_name,
     mp.phase_key,
     mp.phase_name_simple
-   FROM moped_proj_phases mpp
-     LEFT JOIN moped_phases mp ON mp.phase_id = mpp.phase_id
-  WHERE mpp.is_deleted = false AND mpp.is_current_phase = true;
+FROM moped_proj_phases mpp
+LEFT JOIN moped_phases mp ON mp.phase_id = mpp.phase_id
+WHERE mpp.is_deleted = false AND mpp.is_current_phase = true;
diff --git a/moped-database/views/exploded_component_arcgis_online_view.sql b/moped-database/views/exploded_component_arcgis_online_view.sql
index 7cba494462..1cb5feec7a 100644
--- a/moped-database/views/exploded_component_arcgis_online_view.sql
+++ b/moped-database/views/exploded_component_arcgis_online_view.sql
@@ -1,12 +1,13 @@
 -- Most recent migration: moped-database/migrations/default/1739832264647_agol_view_is_mapped_bool/up.sql
 
-CREATE OR REPLACE VIEW exploded_component_arcgis_online_view AS  SELECT component_arcgis_online_view.project_id,
+CREATE OR REPLACE VIEW exploded_component_arcgis_online_view AS SELECT
+    component_arcgis_online_view.project_id,
     component_arcgis_online_view.project_component_id,
     st_geometrytype(dump.geom) AS geometry_type,
     dump.path[1] AS point_index,
     component_arcgis_online_view.geometry AS original_geometry,
     st_asgeojson(dump.geom) AS exploded_geometry,
     component_arcgis_online_view.project_updated_at
-   FROM component_arcgis_online_view,
-    LATERAL st_dump(st_geomfromgeojson(component_arcgis_online_view.geometry)) dump(path, geom)
-  WHERE st_geometrytype(st_geomfromgeojson(component_arcgis_online_view.geometry)) = 'ST_MultiPoint'::text;
+FROM component_arcgis_online_view,
+    LATERAL st_dump(st_geomfromgeojson(component_arcgis_online_view.geometry)) dump (path, geom)
+WHERE st_geometrytype(st_geomfromgeojson(component_arcgis_online_view.geometry)) = 'ST_MultiPoint'::text;
diff --git a/moped-database/views/project_funding_view.sql b/moped-database/views/project_funding_view.sql
index d6598e3f24..11b0e69753 100644
--- a/moped-database/views/project_funding_view.sql
+++ b/moped-database/views/project_funding_view.sql
@@ -1,6 +1,7 @@
 -- Most recent migration: moped-database/migrations/default/1736291471358_add_proj_fund_view/up.sql
 
-CREATE OR REPLACE VIEW project_funding_view AS  SELECT mp.project_id,
+CREATE OR REPLACE VIEW project_funding_view AS SELECT
+    mp.project_id,
     mpf.proj_funding_id,
     mpf.funding_amount,
     mpf.funding_description,
@@ -10,9 +11,9 @@ CREATE OR REPLACE VIEW project_funding_view AS  SELECT mp.project_id,
     mfs.funding_source_name,
     mfp.funding_program_name,
     mfst.funding_status_name
-   FROM moped_project mp
-     LEFT JOIN moped_proj_funding mpf ON mp.project_id = mpf.project_id
-     LEFT JOIN moped_fund_sources mfs ON mpf.funding_source_id = mfs.funding_source_id
-     LEFT JOIN moped_fund_programs mfp ON mpf.funding_program_id = mfp.funding_program_id
-     LEFT JOIN moped_fund_status mfst ON mpf.funding_status_id = mfst.funding_status_id
-  WHERE true AND mp.is_deleted = false AND mpf.is_deleted = false;
+FROM moped_project mp
+LEFT JOIN moped_proj_funding mpf ON mp.project_id = mpf.project_id
+LEFT JOIN moped_fund_sources mfs ON mpf.funding_source_id = mfs.funding_source_id
+LEFT JOIN moped_fund_programs mfp ON mpf.funding_program_id = mfp.funding_program_id
+LEFT JOIN moped_fund_status mfst ON mpf.funding_status_id = mfst.funding_status_id
+WHERE true AND mp.is_deleted = false AND mpf.is_deleted = false;
diff --git a/moped-database/views/project_geography.sql b/moped-database/views/project_geography.sql
index ab5a28f507..546e8564df 100644
--- a/moped-database/views/project_geography.sql
+++ b/moped-database/views/project_geography.sql
@@ -1,6 +1,7 @@
 -- Most recent migration: moped-database/migrations/default/1727229291111_add_beacons_to_unified_features_view/up.sql
 
-CREATE OR REPLACE VIEW project_geography AS  SELECT moped_project.project_id,
+CREATE OR REPLACE VIEW project_geography AS SELECT
+    moped_project.project_id,
     uniform_features.id AS feature_id,
     moped_components.component_id AS component_archtype_id,
     moped_components.line_representation,
@@ -18,9 +19,9 @@ CREATE OR REPLACE VIEW project_geography AS  SELECT moped_project.project_id,
     uniform_features.updated_at AS feature_updated_at,
     uniform_features.created_by_user_id AS feature_created_by_user_id,
     uniform_features.updated_by_user_id AS feature_updated_by_user_id
-   FROM moped_project
-     JOIN moped_proj_components ON moped_project.project_id = moped_proj_components.project_id
-     JOIN moped_components ON moped_proj_components.component_id = moped_components.component_id
-     JOIN feature_layers ON moped_components.feature_layer_id = feature_layers.id
-     JOIN uniform_features ON moped_proj_components.project_component_id = uniform_features.component_id
-  WHERE moped_proj_components.is_deleted IS FALSE;
+FROM moped_project
+JOIN moped_proj_components ON moped_project.project_id = moped_proj_components.project_id
+JOIN moped_components ON moped_proj_components.component_id = moped_components.component_id
+JOIN feature_layers ON moped_components.feature_layer_id = feature_layers.id
+JOIN uniform_features ON moped_proj_components.project_component_id = uniform_features.component_id
+WHERE moped_proj_components.is_deleted IS FALSE;
diff --git a/moped-database/views/project_list_view.sql b/moped-database/views/project_list_view.sql
index 8608e7db8d..b47d90260e 100644
--- a/moped-database/views/project_list_view.sql
+++ b/moped-database/views/project_list_view.sql
@@ -1,135 +1,186 @@
 -- Most recent migration: moped-database/migrations/default/1739832264644_update_districts_to_jsonb/up.sql
 
-CREATE OR REPLACE VIEW project_list_view AS  WITH project_person_list_lookup AS (
-         SELECT mpp.project_id,
-            string_agg(DISTINCT concat(mu.first_name, ' ', mu.last_name, ':', mpr.project_role_name), ','::text) AS project_team_members
-           FROM moped_proj_personnel mpp
-             JOIN moped_users mu ON mpp.user_id = mu.user_id
-             JOIN moped_proj_personnel_roles mppr ON mpp.project_personnel_id = mppr.project_personnel_id
-             JOIN moped_project_roles mpr ON mppr.project_role_id = mpr.project_role_id
-          WHERE mpp.is_deleted = false AND mppr.is_deleted = false
-          GROUP BY mpp.project_id
-        ), funding_sources_lookup AS (
-         SELECT mpf.project_id,
-            string_agg(DISTINCT mfs.funding_source_name, ', '::text ORDER BY mfs.funding_source_name) AS funding_source_name,
-            string_agg(DISTINCT mfp.funding_program_name, ', '::text ORDER BY mfp.funding_program_name) AS funding_program_names,
-            string_agg(DISTINCT
-                CASE
-                    WHEN mfs.funding_source_name IS NOT NULL AND mfp.funding_program_name IS NOT NULL THEN concat(mfs.funding_source_name, ' - ', mfp.funding_program_name)
-                    WHEN mfs.funding_source_name IS NOT NULL THEN mfs.funding_source_name
-                    WHEN mfp.funding_program_name IS NOT NULL THEN mfp.funding_program_name
-                    ELSE NULL::text
-                END, ', '::text ORDER BY (
+CREATE OR REPLACE VIEW project_list_view AS WITH project_person_list_lookup AS (
+    SELECT
+        mpp.project_id,
+        string_agg(DISTINCT concat(mu.first_name, ' ', mu.last_name, ':', mpr.project_role_name), ','::text) AS project_team_members
+    FROM moped_proj_personnel mpp
+    JOIN moped_users mu ON mpp.user_id = mu.user_id
+    JOIN moped_proj_personnel_roles mppr ON mpp.project_personnel_id = mppr.project_personnel_id
+    JOIN moped_project_roles mpr ON mppr.project_role_id = mpr.project_role_id
+    WHERE mpp.is_deleted = false AND mppr.is_deleted = false
+    GROUP BY mpp.project_id
+),
+
+funding_sources_lookup AS (
+    SELECT
+        mpf.project_id,
+        string_agg(
+            DISTINCT mfs.funding_source_name, ', '::text
+            ORDER BY mfs.funding_source_name
+        ) AS funding_source_name,
+        string_agg(
+            DISTINCT mfp.funding_program_name, ', '::text
+            ORDER BY mfp.funding_program_name
+        ) AS funding_program_names,
+        string_agg(
+            DISTINCT
+            CASE
+                WHEN mfs.funding_source_name IS NOT null AND mfp.funding_program_name IS NOT null THEN concat(mfs.funding_source_name, ' - ', mfp.funding_program_name)
+                WHEN mfs.funding_source_name IS NOT null THEN mfs.funding_source_name
+                WHEN mfp.funding_program_name IS NOT null THEN mfp.funding_program_name
+                ELSE null::text
+            END, ', '::text
+            ORDER BY (
                 CASE
-                    WHEN mfs.funding_source_name IS NOT NULL AND mfp.funding_program_name IS NOT NULL THEN concat(mfs.funding_source_name, ' - ', mfp.funding_program_name)
-                    WHEN mfs.funding_source_name IS NOT NULL THEN mfs.funding_source_name
-                    WHEN mfp.funding_program_name IS NOT NULL THEN mfp.funding_program_name
-                    ELSE NULL::text
-                END)) AS funding_source_and_program_names
-           FROM moped_proj_funding mpf
-             LEFT JOIN moped_fund_sources mfs ON mpf.funding_source_id = mfs.funding_source_id
-             LEFT JOIN moped_fund_programs mfp ON mpf.funding_program_id = mfp.funding_program_id
-          WHERE mpf.is_deleted = false
-          GROUP BY mpf.project_id
-        ), child_project_lookup AS (
-         SELECT jsonb_agg(children.project_id) AS children_project_ids,
-            children.parent_project_id AS parent_id
-           FROM moped_project children
-             JOIN moped_project parent ON children.parent_project_id = parent.project_id
-          WHERE children.is_deleted = false
-          GROUP BY children.parent_project_id
-        ), work_activities AS (
-         SELECT mpwa.project_id,
-            string_agg(task_order_objects.task_order_object ->> 'display_name'::text, ', '::text) AS task_order_names,
-            string_agg(task_order_objects.task_order_object ->> 'task_order'::text, ', '::text) AS task_order_names_short,
-            jsonb_agg(DISTINCT task_order_objects.task_order_object) FILTER (WHERE task_order_objects.task_order_object IS NOT NULL) AS task_orders,
-            string_agg(DISTINCT mpwa.workgroup_contractor, ', '::text) AS workgroup_contractors,
-            string_agg(mpwa.contract_number, ', '::text) AS contract_numbers
-           FROM moped_proj_work_activity mpwa
-             LEFT JOIN LATERAL jsonb_array_elements(mpwa.task_orders) task_order_objects(task_order_object) ON true
-          WHERE 1 = 1 AND mpwa.is_deleted = false
-          GROUP BY mpwa.project_id
-        ), moped_proj_components_subtypes AS (
-         SELECT mpc.project_id,
-            string_agg(DISTINCT mc.component_name_full, ', '::text) AS components
-           FROM moped_proj_components mpc
-             LEFT JOIN moped_components mc ON mpc.component_id = mc.component_id
-          WHERE mpc.is_deleted = false
-          GROUP BY mpc.project_id
-        ), project_district_association AS (
-         WITH project_council_district_map AS (
-                 SELECT DISTINCT moped_project.project_id,
-                    features_council_districts.council_district_id
-                   FROM moped_project
-                     LEFT JOIN moped_proj_components ON moped_project.project_id = moped_proj_components.project_id
-                     LEFT JOIN features ON moped_proj_components.project_component_id = features.component_id
-                     LEFT JOIN features_council_districts ON features.id = features_council_districts.feature_id
-                  WHERE features.is_deleted IS FALSE AND moped_proj_components.is_deleted IS FALSE
-                ), parent_child_project_map AS (
-                 SELECT parent_projects.project_id,
-                    unnest(ARRAY[parent_projects.project_id] || array_agg(child_projects.project_id)) AS self_and_children_project_ids
-                   FROM moped_project parent_projects
-                     LEFT JOIN moped_project child_projects ON parent_projects.project_id = child_projects.parent_project_id
-                  GROUP BY parent_projects.project_id
-                  ORDER BY parent_projects.project_id
-                )
-         SELECT projects.project_id,
-            jsonb_agg(DISTINCT project_districts.council_district_id) FILTER (WHERE project_districts.council_district_id IS NOT NULL) AS project_council_districts,
-            jsonb_agg(DISTINCT project_and_children_districts.council_district_id) FILTER (WHERE project_and_children_districts.council_district_id IS NOT NULL) AS project_and_child_project_council_districts
-           FROM parent_child_project_map projects
-             LEFT JOIN project_council_district_map project_and_children_districts ON projects.self_and_children_project_ids = project_and_children_districts.project_id
-             LEFT JOIN project_council_district_map project_districts ON projects.project_id = project_districts.project_id
-          GROUP BY projects.project_id
-        ), min_confirmed_phase_dates AS (
-         WITH min_dates AS (
-                 SELECT phases.project_id,
-                    min(phases.phase_start) AS min_date
-                   FROM moped_proj_phases phases
-                     LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
-                  WHERE true AND phases.phase_start IS NOT NULL AND phases.is_phase_start_confirmed = true AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
-                  GROUP BY phases.project_id
-                UNION ALL
-                 SELECT phases.project_id,
-                    min(phases.phase_end) AS min_date
-                   FROM moped_proj_phases phases
-                     LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
-                  WHERE true AND phases.phase_end IS NOT NULL AND phases.is_phase_end_confirmed = true AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
-                  GROUP BY phases.project_id
-                )
-         SELECT min_dates.project_id,
-            min(min_dates.min_date) AS min_phase_date
-           FROM min_dates
-          GROUP BY min_dates.project_id
-        ), min_estimated_phase_dates AS (
-         WITH min_dates AS (
-                 SELECT phases.project_id,
-                    min(phases.phase_start) AS min_date
-                   FROM moped_proj_phases phases
-                     LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
-                  WHERE true AND phases.phase_start IS NOT NULL AND phases.is_phase_start_confirmed = false AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
-                  GROUP BY phases.project_id
-                UNION ALL
-                 SELECT phases.project_id,
-                    min(phases.phase_end) AS min_date
-                   FROM moped_proj_phases phases
-                     LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
-                  WHERE true AND phases.phase_end IS NOT NULL AND phases.is_phase_end_confirmed = false AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
-                  GROUP BY phases.project_id
-                )
-         SELECT min_dates.project_id,
-            min(min_dates.min_date) AS min_phase_date
-           FROM min_dates
-          GROUP BY min_dates.project_id
-        ), project_component_work_types AS (
-         SELECT mpc.project_id,
-            string_agg(DISTINCT mwt.name, ', '::text ORDER BY mwt.name) AS component_work_type_names
-           FROM moped_proj_components mpc
-             LEFT JOIN moped_proj_component_work_types mpcwt ON mpc.project_component_id = mpcwt.project_component_id
-             LEFT JOIN moped_work_types mwt ON mpcwt.work_type_id = mwt.id
-          WHERE true AND mpc.is_deleted = false AND mpcwt.is_deleted = false
-          GROUP BY mpc.project_id
-        )
- SELECT mp.project_id,
+                    WHEN mfs.funding_source_name IS NOT null AND mfp.funding_program_name IS NOT null THEN concat(mfs.funding_source_name, ' - ', mfp.funding_program_name)
+                    WHEN mfs.funding_source_name IS NOT null THEN mfs.funding_source_name
+                    WHEN mfp.funding_program_name IS NOT null THEN mfp.funding_program_name
+                    ELSE null::text
+                END
+            )
+        ) AS funding_source_and_program_names
+    FROM moped_proj_funding mpf
+    LEFT JOIN moped_fund_sources mfs ON mpf.funding_source_id = mfs.funding_source_id
+    LEFT JOIN moped_fund_programs mfp ON mpf.funding_program_id = mfp.funding_program_id
+    WHERE mpf.is_deleted = false
+    GROUP BY mpf.project_id
+),
+
+child_project_lookup AS (
+    SELECT
+        jsonb_agg(children.project_id) AS children_project_ids,
+        children.parent_project_id AS parent_id
+    FROM moped_project children
+    JOIN moped_project parent ON children.parent_project_id = parent.project_id
+    WHERE children.is_deleted = false
+    GROUP BY children.parent_project_id
+),
+
+work_activities AS (
+    SELECT
+        mpwa.project_id,
+        string_agg(task_order_objects.task_order_object ->> 'display_name'::text, ', '::text) AS task_order_names,
+        string_agg(task_order_objects.task_order_object ->> 'task_order'::text, ', '::text) AS task_order_names_short,
+        jsonb_agg(DISTINCT task_order_objects.task_order_object) FILTER (WHERE task_order_objects.task_order_object IS NOT null) AS task_orders,
+        string_agg(DISTINCT mpwa.workgroup_contractor, ', '::text) AS workgroup_contractors,
+        string_agg(mpwa.contract_number, ', '::text) AS contract_numbers
+    FROM moped_proj_work_activity mpwa
+    LEFT JOIN LATERAL jsonb_array_elements(mpwa.task_orders) task_order_objects (task_order_object) ON true
+    WHERE 1 = 1 AND mpwa.is_deleted = false
+    GROUP BY mpwa.project_id
+),
+
+moped_proj_components_subtypes AS (
+    SELECT
+        mpc.project_id,
+        string_agg(DISTINCT mc.component_name_full, ', '::text) AS components
+    FROM moped_proj_components mpc
+    LEFT JOIN moped_components mc ON mpc.component_id = mc.component_id
+    WHERE mpc.is_deleted = false
+    GROUP BY mpc.project_id
+),
+
+project_district_association AS (
+    WITH project_council_district_map AS (
+        SELECT DISTINCT
+            moped_project.project_id,
+            features_council_districts.council_district_id
+        FROM moped_project
+        LEFT JOIN moped_proj_components ON moped_project.project_id = moped_proj_components.project_id
+        LEFT JOIN features ON moped_proj_components.project_component_id = features.component_id
+        LEFT JOIN features_council_districts ON features.id = features_council_districts.feature_id
+        WHERE features.is_deleted IS false AND moped_proj_components.is_deleted IS false
+    ),
+
+parent_child_project_map AS (
+        SELECT
+            parent_projects.project_id,
+            unnest(ARRAY[parent_projects.project_id] || array_agg(child_projects.project_id)) AS self_and_children_project_ids
+        FROM moped_project parent_projects
+        LEFT JOIN moped_project child_projects ON parent_projects.project_id = child_projects.parent_project_id
+        GROUP BY parent_projects.project_id
+        ORDER BY parent_projects.project_id
+    )
+
+    SELECT
+        projects.project_id,
+        jsonb_agg(DISTINCT project_districts.council_district_id) FILTER (WHERE project_districts.council_district_id IS NOT null) AS project_council_districts,
+        jsonb_agg(DISTINCT project_and_children_districts.council_district_id) FILTER (WHERE project_and_children_districts.council_district_id IS NOT null) AS project_and_child_project_council_districts
+    FROM parent_child_project_map projects
+    LEFT JOIN project_council_district_map project_and_children_districts ON projects.self_and_children_project_ids = project_and_children_districts.project_id
+    LEFT JOIN project_council_district_map project_districts ON projects.project_id = project_districts.project_id
+    GROUP BY projects.project_id
+),
+
+min_confirmed_phase_dates AS (
+    WITH min_dates AS (
+        SELECT
+            phases.project_id,
+            min(phases.phase_start) AS min_date
+        FROM moped_proj_phases phases
+        LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
+        WHERE true AND phases.phase_start IS NOT null AND phases.is_phase_start_confirmed = true AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
+        GROUP BY phases.project_id
+        UNION ALL
+        SELECT
+            phases.project_id,
+            min(phases.phase_end) AS min_date
+        FROM moped_proj_phases phases
+        LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
+        WHERE true AND phases.phase_end IS NOT null AND phases.is_phase_end_confirmed = true AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
+        GROUP BY phases.project_id
+    )
+
+    SELECT
+        min_dates.project_id,
+        min(min_dates.min_date) AS min_phase_date
+    FROM min_dates
+    GROUP BY min_dates.project_id
+),
+
+min_estimated_phase_dates AS (
+    WITH min_dates AS (
+        SELECT
+            phases.project_id,
+            min(phases.phase_start) AS min_date
+        FROM moped_proj_phases phases
+        LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
+        WHERE true AND phases.phase_start IS NOT null AND phases.is_phase_start_confirmed = false AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
+        GROUP BY phases.project_id
+        UNION ALL
+        SELECT
+            phases.project_id,
+            min(phases.phase_end) AS min_date
+        FROM moped_proj_phases phases
+        LEFT JOIN moped_phases ON phases.phase_id = moped_phases.phase_id
+        WHERE true AND phases.phase_end IS NOT null AND phases.is_phase_end_confirmed = false AND moped_phases.phase_name_simple = 'Complete'::text AND phases.is_deleted = false
+        GROUP BY phases.project_id
+    )
+
+    SELECT
+        min_dates.project_id,
+        min(min_dates.min_date) AS min_phase_date
+    FROM min_dates
+    GROUP BY min_dates.project_id
+),
+
+project_component_work_types AS (
+    SELECT
+        mpc.project_id,
+        string_agg(
+            DISTINCT mwt.name, ', '::text
+            ORDER BY mwt.name
+        ) AS component_work_type_names
+    FROM moped_proj_components mpc
+    LEFT JOIN moped_proj_component_work_types mpcwt ON mpc.project_component_id = mpcwt.project_component_id
+    LEFT JOIN moped_work_types mwt ON mpcwt.work_type_id = mwt.id
+    WHERE true AND mpc.is_deleted = false AND mpcwt.is_deleted = false
+    GROUP BY mpc.project_id
+)
+
+SELECT
+    mp.project_id,
     mp.project_name_full,
     mp.project_name,
     mp.project_name_secondary,
@@ -158,72 +209,87 @@ CREATE OR REPLACE VIEW project_list_view AS  WITH project_person_list_lookup AS
     work_activities.task_order_names,
     work_activities.task_order_names_short,
     work_activities.task_orders,
-    ( SELECT moped_project.project_name_full
-           FROM moped_project
-          WHERE moped_project.project_id = mp.parent_project_id) AS parent_project_name,
+    (
+        SELECT moped_project.project_name_full
+        FROM moped_project
+        WHERE moped_project.project_id = mp.parent_project_id
+    ) AS parent_project_name,
     cpl.children_project_ids,
     string_agg(DISTINCT me2.entity_name, ', '::text) AS project_partners,
-    ( SELECT json_agg(json_build_object('signal_id', feature_signals.signal_id, 'knack_id', feature_signals.knack_id, 'location_name', feature_signals.location_name, 'signal_type', feature_signals.signal_type, 'id', feature_signals.id)) AS json_agg
-           FROM moped_proj_components components
-             LEFT JOIN feature_signals ON components.project_component_id = feature_signals.component_id
-          WHERE true AND components.is_deleted = false AND components.project_id = mp.project_id AND feature_signals.signal_id IS NOT NULL AND feature_signals.is_deleted = false) AS project_feature,
+    (
+        SELECT json_agg(json_build_object('signal_id', feature_signals.signal_id, 'knack_id', feature_signals.knack_id, 'location_name', feature_signals.location_name, 'signal_type', feature_signals.signal_type, 'id', feature_signals.id)) AS json_agg
+        FROM moped_proj_components components
+        LEFT JOIN feature_signals ON components.project_component_id = feature_signals.component_id
+        WHERE true AND components.is_deleted = false AND components.project_id = mp.project_id AND feature_signals.signal_id IS NOT null AND feature_signals.is_deleted = false
+    ) AS project_feature,
     fsl.funding_source_name,
     fsl.funding_program_names,
     fsl.funding_source_and_program_names,
-    ( SELECT min(phases.phase_start) AS min
-           FROM moped_proj_phases phases
-          WHERE true AND phases.project_id = mp.project_id AND phases.phase_id = 9 AND phases.is_deleted = false) AS construction_start_date,
+    (
+        SELECT min(phases.phase_start) AS min
+        FROM moped_proj_phases phases
+        WHERE true AND phases.project_id = mp.project_id AND phases.phase_id = 9 AND phases.is_deleted = false
+    ) AS construction_start_date,
     mcpd.min_phase_date AS substantial_completion_date,
-        CASE
-            WHEN mcpd.min_phase_date IS NOT NULL THEN NULL::timestamp with time zone
-            ELSE mepd.min_phase_date
-        END AS substantial_completion_date_estimated,
-    ( SELECT string_agg(concat(users.first_name, ' ', users.last_name), ', '::text) AS string_agg
-           FROM moped_proj_personnel mpp
-             JOIN moped_users users ON mpp.user_id = users.user_id
-             JOIN moped_proj_personnel_roles mppr ON mpp.project_personnel_id = mppr.project_personnel_id
-             JOIN moped_project_roles mpr ON mppr.project_role_id = mpr.project_role_id
-          WHERE 1 = 1 AND mpr.project_role_name = 'Inspector'::text AND mpp.is_deleted = false AND mppr.is_deleted = false AND mpp.project_id = mp.project_id
-          GROUP BY mpp.project_id) AS project_inspector,
-    ( SELECT string_agg(concat(users.first_name, ' ', users.last_name), ', '::text) AS string_agg
-           FROM moped_proj_personnel mpp
-             JOIN moped_users users ON mpp.user_id = users.user_id
-             JOIN moped_proj_personnel_roles mppr ON mpp.project_personnel_id = mppr.project_personnel_id
-             JOIN moped_project_roles mpr ON mppr.project_role_id = mpr.project_role_id
-          WHERE 1 = 1 AND mpr.project_role_name = 'Designer'::text AND mpp.is_deleted = false AND mppr.is_deleted = false AND mpp.project_id = mp.project_id
-          GROUP BY mpp.project_id) AS project_designer,
-    ( SELECT string_agg(tags.name, ', '::text) AS string_agg
-           FROM moped_proj_tags ptags
-             JOIN moped_tags tags ON ptags.tag_id = tags.id
-          WHERE 1 = 1 AND ptags.is_deleted = false AND ptags.project_id = mp.project_id
-          GROUP BY ptags.project_id) AS project_tags,
+    CASE
+        WHEN mcpd.min_phase_date IS NOT null THEN null::timestamp with time zone
+        ELSE mepd.min_phase_date
+    END AS substantial_completion_date_estimated,
+    (
+        SELECT string_agg(concat(users.first_name, ' ', users.last_name), ', '::text) AS string_agg
+        FROM moped_proj_personnel mpp
+        JOIN moped_users users ON mpp.user_id = users.user_id
+        JOIN moped_proj_personnel_roles mppr ON mpp.project_personnel_id = mppr.project_personnel_id
+        JOIN moped_project_roles mpr ON mppr.project_role_id = mpr.project_role_id
+        WHERE 1 = 1 AND mpr.project_role_name = 'Inspector'::text AND mpp.is_deleted = false AND mppr.is_deleted = false AND mpp.project_id = mp.project_id
+        GROUP BY mpp.project_id
+    ) AS project_inspector,
+    (
+        SELECT string_agg(concat(users.first_name, ' ', users.last_name), ', '::text) AS string_agg
+        FROM moped_proj_personnel mpp
+        JOIN moped_users users ON mpp.user_id = users.user_id
+        JOIN moped_proj_personnel_roles mppr ON mpp.project_personnel_id = mppr.project_personnel_id
+        JOIN moped_project_roles mpr ON mppr.project_role_id = mpr.project_role_id
+        WHERE 1 = 1 AND mpr.project_role_name = 'Designer'::text AND mpp.is_deleted = false AND mppr.is_deleted = false AND mpp.project_id = mp.project_id
+        GROUP BY mpp.project_id
+    ) AS project_designer,
+    (
+        SELECT string_agg(tags.name, ', '::text) AS string_agg
+        FROM moped_proj_tags ptags
+        JOIN moped_tags tags ON ptags.tag_id = tags.id
+        WHERE 1 = 1 AND ptags.is_deleted = false AND ptags.project_id = mp.project_id
+        GROUP BY ptags.project_id
+    ) AS project_tags,
     concat(added_by_user.first_name, ' ', added_by_user.last_name) AS added_by,
     mpcs.components,
     districts.project_council_districts,
     districts.project_and_child_project_council_districts,
     pcwt.component_work_type_names
-   FROM moped_project mp
-     LEFT JOIN project_person_list_lookup ppll ON mp.project_id = ppll.project_id
-     LEFT JOIN funding_sources_lookup fsl ON mp.project_id = fsl.project_id
-     LEFT JOIN moped_entity me ON mp.project_sponsor = me.entity_id
-     LEFT JOIN moped_entity mel ON mp.project_lead_id = mel.entity_id
-     LEFT JOIN moped_proj_partners mpp2 ON mp.project_id = mpp2.project_id AND mpp2.is_deleted = false
-     LEFT JOIN moped_entity me2 ON mpp2.entity_id = me2.entity_id
-     LEFT JOIN work_activities ON mp.project_id = work_activities.project_id
-     LEFT JOIN moped_users added_by_user ON mp.added_by = added_by_user.user_id
-     LEFT JOIN current_phase_view current_phase ON mp.project_id = current_phase.project_id
-     LEFT JOIN moped_public_process_statuses mpps ON mp.public_process_status_id = mpps.id
-     LEFT JOIN child_project_lookup cpl ON mp.project_id = cpl.parent_id
-     LEFT JOIN moped_proj_components_subtypes mpcs ON mp.project_id = mpcs.project_id
-     LEFT JOIN project_district_association districts ON mp.project_id = districts.project_id
-     LEFT JOIN min_confirmed_phase_dates mcpd ON mp.project_id = mcpd.project_id
-     LEFT JOIN min_estimated_phase_dates mepd ON mp.project_id = mepd.project_id
-     LEFT JOIN project_component_work_types pcwt ON mp.project_id = pcwt.project_id
-     LEFT JOIN LATERAL ( SELECT mpn.project_note,
-            mpn.created_at AS date_created
-           FROM moped_proj_notes mpn
-          WHERE mpn.project_id = mp.project_id AND mpn.project_note_type = 2 AND mpn.is_deleted = false
-          ORDER BY mpn.created_at DESC
-         LIMIT 1) proj_status_update ON true
-  WHERE mp.is_deleted = false
-  GROUP BY mp.project_id, mp.project_name, mp.project_description, ppll.project_team_members, mp.ecapris_subproject_id, mp.date_added, mp.is_deleted, me.entity_name, mel.entity_name, mp.updated_at, mp.interim_project_id, mp.parent_project_id, mp.knack_project_id, current_phase.phase_name, current_phase.phase_key, current_phase.phase_name_simple, mpcs.components, fsl.funding_source_name, fsl.funding_program_names, fsl.funding_source_and_program_names, added_by_user.first_name, added_by_user.last_name, mpps.name, cpl.children_project_ids, proj_status_update.project_note, proj_status_update.date_created, work_activities.workgroup_contractors, work_activities.contract_numbers, work_activities.task_order_names, work_activities.task_order_names_short, work_activities.task_orders, districts.project_council_districts, districts.project_and_child_project_council_districts, mepd.min_phase_date, mcpd.min_phase_date, pcwt.component_work_type_names;
+FROM moped_project mp
+LEFT JOIN project_person_list_lookup ppll ON mp.project_id = ppll.project_id
+LEFT JOIN funding_sources_lookup fsl ON mp.project_id = fsl.project_id
+LEFT JOIN moped_entity me ON mp.project_sponsor = me.entity_id
+LEFT JOIN moped_entity mel ON mp.project_lead_id = mel.entity_id
+LEFT JOIN moped_proj_partners mpp2 ON mp.project_id = mpp2.project_id AND mpp2.is_deleted = false
+LEFT JOIN moped_entity me2 ON mpp2.entity_id = me2.entity_id
+LEFT JOIN work_activities ON mp.project_id = work_activities.project_id
+LEFT JOIN moped_users added_by_user ON mp.added_by = added_by_user.user_id
+LEFT JOIN current_phase_view current_phase ON mp.project_id = current_phase.project_id
+LEFT JOIN moped_public_process_statuses mpps ON mp.public_process_status_id = mpps.id
+LEFT JOIN child_project_lookup cpl ON mp.project_id = cpl.parent_id
+LEFT JOIN moped_proj_components_subtypes mpcs ON mp.project_id = mpcs.project_id
+LEFT JOIN project_district_association districts ON mp.project_id = districts.project_id
+LEFT JOIN min_confirmed_phase_dates mcpd ON mp.project_id = mcpd.project_id
+LEFT JOIN min_estimated_phase_dates mepd ON mp.project_id = mepd.project_id
+LEFT JOIN project_component_work_types pcwt ON mp.project_id = pcwt.project_id
+LEFT JOIN LATERAL (
+    SELECT
+        mpn.project_note,
+        mpn.created_at AS date_created
+    FROM moped_proj_notes mpn
+    WHERE mpn.project_id = mp.project_id AND mpn.project_note_type = 2 AND mpn.is_deleted = false
+    ORDER BY mpn.created_at DESC
+    LIMIT 1
+) proj_status_update ON true
+WHERE mp.is_deleted = false
+GROUP BY mp.project_id, mp.project_name, mp.project_description, ppll.project_team_members, mp.ecapris_subproject_id, mp.date_added, mp.is_deleted, me.entity_name, mel.entity_name, mp.updated_at, mp.interim_project_id, mp.parent_project_id, mp.knack_project_id, current_phase.phase_name, current_phase.phase_key, current_phase.phase_name_simple, mpcs.components, fsl.funding_source_name, fsl.funding_program_names, fsl.funding_source_and_program_names, added_by_user.first_name, added_by_user.last_name, mpps.name, cpl.children_project_ids, proj_status_update.project_note, proj_status_update.date_created, work_activities.workgroup_contractors, work_activities.contract_numbers, work_activities.task_order_names, work_activities.task_order_names_short, work_activities.task_orders, districts.project_council_districts, districts.project_and_child_project_council_districts, mepd.min_phase_date, mcpd.min_phase_date, pcwt.component_work_type_names;
diff --git a/moped-database/views/uniform_features.sql b/moped-database/views/uniform_features.sql
index 21ab4ac4c2..5f291f6aff 100644
--- a/moped-database/views/uniform_features.sql
+++ b/moped-database/views/uniform_features.sql
@@ -1,6 +1,7 @@
 -- Most recent migration: moped-database/migrations/default/1727229291111_add_beacons_to_unified_features_view/up.sql
 
-CREATE OR REPLACE VIEW uniform_features AS  SELECT feature_signals.id,
+CREATE OR REPLACE VIEW uniform_features AS SELECT
+    feature_signals.id,
     feature_signals.component_id,
     'feature_signals'::text AS "table",
     json_build_object('signal_id', feature_signals.signal_id, 'knack_id', feature_signals.knack_id, 'location_name', feature_signals.location_name, 'signal_type', feature_signals.signal_type) AS attributes,
@@ -11,14 +12,18 @@ CREATE OR REPLACE VIEW uniform_features AS  SELECT feature_signals.id,
     feature_signals.updated_at,
     feature_signals.created_by_user_id,
     feature_signals.updated_by_user_id
-   FROM feature_signals
-     LEFT JOIN ( SELECT d.feature_id,
-            array_agg(d.council_district_id) AS council_districts
-           FROM features_council_districts d
-          GROUP BY d.feature_id) districts ON feature_signals.id = districts.feature_id
-  WHERE feature_signals.is_deleted = false
+FROM feature_signals
+LEFT JOIN (
+    SELECT
+        d.feature_id,
+        array_agg(d.council_district_id) AS council_districts
+    FROM features_council_districts d
+    GROUP BY d.feature_id
+) districts ON feature_signals.id = districts.feature_id
+WHERE feature_signals.is_deleted = FALSE
 UNION ALL
- SELECT feature_street_segments.id,
+SELECT
+    feature_street_segments.id,
     feature_street_segments.component_id,
     'feature_street_segments'::text AS "table",
     json_build_object('ctn_segment_id', feature_street_segments.ctn_segment_id, 'from_address_min', feature_street_segments.from_address_min, 'to_address_max', feature_street_segments.to_address_max, 'full_street_name', feature_street_segments.full_street_name, 'line_type', feature_street_segments.line_type, 'symbol', feature_street_segments.symbol, 'source_layer', feature_street_segments.source_layer) AS attributes,
@@ -29,14 +34,18 @@ UNION ALL
     feature_street_segments.updated_at,
     feature_street_segments.created_by_user_id,
     feature_street_segments.updated_by_user_id
-   FROM feature_street_segments
-     LEFT JOIN ( SELECT d.feature_id,
-            array_agg(d.council_district_id) AS council_districts
-           FROM features_council_districts d
-          GROUP BY d.feature_id) districts ON feature_street_segments.id = districts.feature_id
-  WHERE feature_street_segments.is_deleted = false
+FROM feature_street_segments
+LEFT JOIN (
+    SELECT
+        d.feature_id,
+        array_agg(d.council_district_id) AS council_districts
+    FROM features_council_districts d
+    GROUP BY d.feature_id
+) districts ON feature_street_segments.id = districts.feature_id
+WHERE feature_street_segments.is_deleted = FALSE
 UNION ALL
- SELECT feature_intersections.id,
+SELECT
+    feature_intersections.id,
     feature_intersections.component_id,
     'feature_intersections'::text AS "table",
     json_build_object('intersection_id', feature_intersections.intersection_id, 'source_layer', feature_intersections.source_layer) AS attributes,
@@ -47,14 +56,18 @@ UNION ALL
     feature_intersections.updated_at,
     feature_intersections.created_by_user_id,
     feature_intersections.updated_by_user_id
-   FROM feature_intersections
-     LEFT JOIN ( SELECT d.feature_id,
-            array_agg(d.council_district_id) AS council_districts
-           FROM features_council_districts d
-          GROUP BY d.feature_id) districts ON feature_intersections.id = districts.feature_id
-  WHERE feature_intersections.is_deleted = false
+FROM feature_intersections
+LEFT JOIN (
+    SELECT
+        d.feature_id,
+        array_agg(d.council_district_id) AS council_districts
+    FROM features_council_districts d
+    GROUP BY d.feature_id
+) districts ON feature_intersections.id = districts.feature_id
+WHERE feature_intersections.is_deleted = FALSE
 UNION ALL
- SELECT feature_drawn_points.id,
+SELECT
+    feature_drawn_points.id,
     feature_drawn_points.component_id,
     'feature_drawn_points'::text AS "table",
     NULL::json AS attributes,
@@ -65,14 +78,18 @@ UNION ALL
     feature_drawn_points.updated_at,
     feature_drawn_points.created_by_user_id,
     feature_drawn_points.updated_by_user_id
-   FROM feature_drawn_points
-     LEFT JOIN ( SELECT d.feature_id,
-            array_agg(d.council_district_id) AS council_districts
-           FROM features_council_districts d
-          GROUP BY d.feature_id) districts ON feature_drawn_points.id = districts.feature_id
-  WHERE feature_drawn_points.is_deleted = false
+FROM feature_drawn_points
+LEFT JOIN (
+    SELECT
+        d.feature_id,
+        array_agg(d.council_district_id) AS council_districts
+    FROM features_council_districts d
+    GROUP BY d.feature_id
+) districts ON feature_drawn_points.id = districts.feature_id
+WHERE feature_drawn_points.is_deleted = FALSE
 UNION ALL
- SELECT feature_drawn_lines.id,
+SELECT
+    feature_drawn_lines.id,
     feature_drawn_lines.component_id,
     'feature_drawn_lines'::text AS "table",
     NULL::json AS attributes,
@@ -83,14 +100,18 @@ UNION ALL
     feature_drawn_lines.updated_at,
     feature_drawn_lines.created_by_user_id,
     feature_drawn_lines.updated_by_user_id
-   FROM feature_drawn_lines
-     LEFT JOIN ( SELECT d.feature_id,
-            array_agg(d.council_district_id) AS council_districts
-           FROM features_council_districts d
-          GROUP BY d.feature_id) districts ON feature_drawn_lines.id = districts.feature_id
-  WHERE feature_drawn_lines.is_deleted = false
+FROM feature_drawn_lines
+LEFT JOIN (
+    SELECT
+        d.feature_id,
+        array_agg(d.council_district_id) AS council_districts
+    FROM features_council_districts d
+    GROUP BY d.feature_id
+) districts ON feature_drawn_lines.id = districts.feature_id
+WHERE feature_drawn_lines.is_deleted = FALSE
 UNION ALL
- SELECT feature_school_beacons.id,
+SELECT
+    feature_school_beacons.id,
     feature_school_beacons.component_id,
     'feature_school_beacons'::text AS "table",
     json_build_object('school_zone_beacon_id', feature_school_beacons.school_zone_beacon_id, 'knack_id', feature_school_beacons.knack_id, 'location_name', feature_school_beacons.location_name, 'zone_name', feature_school_beacons.zone_name, 'beacon_name', feature_school_beacons.beacon_name) AS attributes,
@@ -101,9 +122,12 @@ UNION ALL
     feature_school_beacons.updated_at,
     feature_school_beacons.created_by_user_id,
     feature_school_beacons.updated_by_user_id
-   FROM feature_school_beacons
-     LEFT JOIN ( SELECT d.feature_id,
-            array_agg(d.council_district_id) AS council_districts
-           FROM features_council_districts d
-          GROUP BY d.feature_id) districts ON feature_school_beacons.id = districts.feature_id
-  WHERE feature_school_beacons.is_deleted = false;
+FROM feature_school_beacons
+LEFT JOIN (
+    SELECT
+        d.feature_id,
+        array_agg(d.council_district_id) AS council_districts
+    FROM features_council_districts d
+    GROUP BY d.feature_id
+) districts ON feature_school_beacons.id = districts.feature_id
+WHERE feature_school_beacons.is_deleted = FALSE;
diff --git a/moped-etl/ecapris-statuses/.dockerignore b/moped-etl/ecapris-statuses/.dockerignore
new file mode 100644
index 0000000000..ebfcfbe643
--- /dev/null
+++ b/moped-etl/ecapris-statuses/.dockerignore
@@ -0,0 +1,3 @@
+env_file
+.git%
+__pycache__
diff --git a/moped-etl/ecapris-statuses/Dockerfile b/moped-etl/ecapris-statuses/Dockerfile
new file mode 100644
index 0000000000..184fc91c07
--- /dev/null
+++ b/moped-etl/ecapris-statuses/Dockerfile
@@ -0,0 +1,8 @@
+FROM ghcr.io/oracle/oraclelinux9-instantclient:23
+
+WORKDIR /app
+COPY . /app
+
+RUN dnf update && dnf install -y python3.12 python3.12-pip
+RUN pip3.12 install --upgrade pip
+RUN pip3.12 install -r requirements.txt
diff --git a/moped-etl/ecapris-statuses/README.md b/moped-etl/ecapris-statuses/README.md
new file mode 100644
index 0000000000..db4f6f6a7b
--- /dev/null
+++ b/moped-etl/ecapris-statuses/README.md
@@ -0,0 +1,36 @@
+# eCapris statuses → Moped eCapris statuses table
+
+Python script that transfers eCapris statuses to the Moped database
+
+## Sync eCapris statuses
+
+The script `ecapris_statuses_sync.py` is used to copy eCapris subproject statuses to the Moped database:
+- Statuses are matched by eCapris number which we store in the Moped project table as `ecapris_subproject_id`.
+- The statuses come from an Oracle Data Warehouse and a view set up by FSD called `ATD_SUB_PROJECT_STATUS_VW`.
+- See the secret called `Finance Data Warehouse Oracle DB` for the Data Warehouse credentials used in the environment.
+- The upsert mutation utilizes the unique `subproject_status_id` as a constraint to avoid duplicating eCapris statuses and to consume updates that can happen within 7 days of creation.
+
+## Testing the script locally using Docker Compose
+
+1. Ensure the local Moped stack is running with a current snapshot.
+1. This process queries the Financial Services Department Oracle Data Warehouse so you must be on the VPN.
+1. Configure an `env_file` according to the `env_template` example. Find the Oracle secrets in the secret store entry called `Finance Data Warehouse Oracle DB`.
+1. `docker compose build` to build the container.
+1. Run the script via:
+   ```bash
+   docker compose run ecapris-statuses
+   ```
+
+## Testing the script locally using Airflow
+
+1. Ensure the local Moped stack is running with a current snapshot.
+1. This process queries the Financial Services Department Oracle Data Warehouse so you must be on the VPN.
+1. If you are making code updates, you will want to update the `development` tag Docker image that the Airflow DAG will reference as you makes changes.
+   ```bash
+   # See https://www.docker.com/blog/how-to-rapidly-build-multi-architecture-images-with-buildx/
+   docker buildx build --push \
+   --platform linux/amd64,linux/arm64 \
+   --tag atddocker/atd-moped-etl-ecapris-statuses:development .
+   ```
+1. Start your local Airflow instance ([atd-airflow](https://github.com/cityofaustin/atd-airflow)). You will need to make sure you are running the Airflow web server on a port other than 8080 to prevent conflicts with the Moped local stack. 8082 works well.
+1. The DAG is setup to use `development` as the Docker image tag in the local development stack which references the local Hasura API for queries and mutations done by the ETL. 
diff --git a/moped-etl/ecapris-statuses/docker-compose.yaml b/moped-etl/ecapris-statuses/docker-compose.yaml
new file mode 100644
index 0000000000..8065c4f4df
--- /dev/null
+++ b/moped-etl/ecapris-statuses/docker-compose.yaml
@@ -0,0 +1,9 @@
+services:
+  ecapris-statuses:
+    build:
+      context: .
+    volumes:
+      - .:/app
+    entrypoint: python3.12 /app/ecapris_statuses_sync.py
+    env_file:
+      - env_file
diff --git a/moped-etl/ecapris-statuses/ecapris_statuses_sync.py b/moped-etl/ecapris-statuses/ecapris_statuses_sync.py
new file mode 100644
index 0000000000..c5a1e4a3ce
--- /dev/null
+++ b/moped-etl/ecapris-statuses/ecapris_statuses_sync.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""
+Fetch ecapris status records from the FSD Data Warehouse and sync to Moped ecapris_subproject_statuses table
+"""
+import os
+import sys
+import logging
+
+import oracledb as cx_Oracle
+
+from process.request import make_hasura_request
+from process.queries import GRAPHQL_QUERIES, ORACLE_QUERIES
+from process.logging import get_logger
+from process.time import convert_to_timezone_aware_timestamp
+
+# FSD Data Warehouse
+ORACLE_USER = os.getenv("ORACLE_USER")
+ORACLE_PASSWORD = os.getenv("ORACLE_PASSWORD")
+ORACLE_HOST = os.getenv("ORACLE_HOST")
+ORACLE_PORT = os.getenv("ORACLE_PORT")
+ORACLE_SERVICE = os.getenv("ORACLE_SERVICE")
+
+
+def get_conn(host, port, service, user, password):
+    # Need to run this once if you want to work locally
+    # Change lib_dir to your cx_Oracle library location
+    # https://stackoverflow.com/questions/56119490/cx-oracle-error-dpi-1047-cannot-locate-a-64-bit-oracle-client-library
+    # lib_dir = r"/Users/charliehenry/instantclient_23_3"
+    # cx_Oracle.init_oracle_client(lib_dir="/Users/atd/lib/oracle/instantclient_23_3")
+    cx_Oracle.init_oracle_client()
+    dsn_tns = cx_Oracle.makedsn(host, port, service_name=service)
+    return cx_Oracle.connect(user=user, password=password, dsn=dsn_tns)
+
+
+def main():
+    # Connect to Moped DB and get distinct eCapris subproject IDs set on any project
+    results = make_hasura_request(query=GRAPHQL_QUERIES["subproject_statuses"])
+
+    # eCapris subproject IDs are de-duplicated in the GraphQL query
+    distinct_project_ecapris_ids = [
+        project["ecapris_subproject_id"] for project in results["moped_project"]
+    ]
+    logger.info(
+        f"Found {len(distinct_project_ecapris_ids)} unique eCapris subproject IDs to sync: {', '.join(distinct_project_ecapris_ids)}"
+    )
+
+    # Connect to Oracle DB and query status updates matching those subproject IDs
+    conn = get_conn(
+        ORACLE_HOST, ORACLE_PORT, ORACLE_SERVICE, ORACLE_USER, ORACLE_PASSWORD
+    )
+    cursor = conn.cursor()
+    cursor.prepare(ORACLE_QUERIES["subproject_statuses"])
+
+    # Loop through the distinct project eCapris IDs, query the status updates, and collect them by id
+    statuses_by_ecapris_id = {}
+    no_result_ids = []
+
+    for i, sp_number in enumerate(distinct_project_ecapris_ids):
+        cursor.execute(None, sp_number=sp_number)
+
+        if i == 0:
+            # Use a row factory to convert the results to a list of dictionaries with k/v pairs
+            # See https://python-oracledb.readthedocs.io/en/latest/user_guide/sql_execution.html#changing-query-results-with-rowfactories
+            columns = [col.name for col in cursor.description]
+            cursor.rowfactory = lambda *args: dict(zip(columns, args))
+
+        results = cursor.fetchall()
+
+        if len(results) > 0:
+            statuses_by_ecapris_id[sp_number] = results
+        else:
+            no_result_ids.append(sp_number)
+
+    logger.info(f"No results for eCapris IDs: {', '.join(no_result_ids)}")
+
+    # Loop through the eCapris IDs and upsert the status updates into the Moped DB
+    updated_ecapris_ids = []
+
+    for ecapris_id, statuses in statuses_by_ecapris_id.items():
+        payload = []
+
+        for status in statuses:
+            review_timestamp = status["STATUS_REVIEW_DATE"]
+            timezone_aware_review_timestamp = convert_to_timezone_aware_timestamp(
+                review_timestamp
+            )
+
+            payload.append(
+                {
+                    "subproject_status_id": status["SUB_PROJECT_STATUS_ID"],
+                    "subproject_name": status["SP_NAME"],
+                    "ecapris_subproject_id": status["SP_NUMBER"],
+                    "current_status_fl": status["CURR_STATUS_FL"],
+                    "sub_project_status_desc": status["SUB_PROJECT_STATUS_DESC"],
+                    "review_timestamp": timezone_aware_review_timestamp,
+                    "subproject_status_impacts": status["SUB_PROJECT_STATUS_IMPACTS"],
+                    "summary_description": status["SUMM_DESC"],
+                    "reviewed_by_name": status["REVIEWED_BY"],
+                    "reviewed_by_email": status["REVIEWED_BY_EMAIL"],
+                }
+            )
+
+        results = make_hasura_request(
+            query=GRAPHQL_QUERIES["subproject_statuses_upsert"],
+            variables={"objects": payload},
+        )
+
+        if len(results["insert_ecapris_subproject_statuses"]["returning"]) > 0:
+            updated_ecapris_ids.append(ecapris_id)
+
+    if len(updated_ecapris_ids) == 0:
+        logger.info(
+            "No new eCapris statuses were found for subproject IDs associated with Moped projects."
+        )
+    else:
+        logger.info(
+            f"Upserted eCapris statuses for eCapris IDs: {', '.join(updated_ecapris_ids)}"
+        )
+
+
+if __name__ == "__main__":
+    log_level = logging.DEBUG
+    logger = get_logger(name="moped-ecapris-statuses-sync", level=log_level)
+    logger.info(
+        f"Starting sync. Transferring eCapris status updates from FSD Data Warehouse to Moped DB."
+    )
+
+    main()
diff --git a/moped-etl/ecapris-statuses/env_template b/moped-etl/ecapris-statuses/env_template
new file mode 100644
index 0000000000..d5f1a7a3df
--- /dev/null
+++ b/moped-etl/ecapris-statuses/env_template
@@ -0,0 +1,8 @@
+
+ORACLE_USER=
+ORACLE_PASSWORD=
+ORACLE_HOST=
+ORACLE_PORT=
+ORACLE_SERVICE=
+HASURA_ENDPOINT=http://host.docker.internal:8080/v1/graphql
+HASURA_ADMIN_SECRET=hasurapassword
diff --git a/moped-etl/ecapris-statuses/process/logging.py b/moped-etl/ecapris-statuses/process/logging.py
new file mode 100644
index 0000000000..28ebae76c6
--- /dev/null
+++ b/moped-etl/ecapris-statuses/process/logging.py
@@ -0,0 +1,13 @@
+import sys
+import logging
+
+
+def get_logger(name, level=logging.DEBUG):
+    """Return a module logger that streams to stdout"""
+    logger = logging.getLogger(name)
+    handler = logging.StreamHandler(stream=sys.stdout)
+    formatter = logging.Formatter(fmt=" %(name)s.%(levelname)s: %(message)s")
+    handler.setFormatter(formatter)
+    logger.addHandler(handler)
+    logger.setLevel(level)
+    return logger
diff --git a/moped-etl/ecapris-statuses/process/queries.py b/moped-etl/ecapris-statuses/process/queries.py
new file mode 100644
index 0000000000..f1d234fc2f
--- /dev/null
+++ b/moped-etl/ecapris-statuses/process/queries.py
@@ -0,0 +1,54 @@
+"""
+Queries for FSD Data Warehouse
+
+Oracle does not store trailing zeroes on numbers, but we need them to match eCapris subproject IDs in Moped DB which have them.
+Ex. eCapris ID 10553.030 is stored as 10553.030 in Moped DB, but stored as 10553.03 in the Oracle DB.
+So, we format the SP_NUMBER to have 3 decimal places using its defined Oracle type Number(18,3).
+'FM999999999999999.000' formats the number to have 3 decimal places with trailing zeroes and 18 digits total.
+"""
+
+ORACLE_QUERIES = {
+    "subproject_statuses": """
+    SELECT
+        TO_CHAR(SP_NUMBER, 'FM999999999999999.000') AS SP_NUMBER,
+        SP_NAME,
+        SUB_PROJECT_STATUS_ID,
+        CURR_STATUS_FL, 
+        SUB_PROJECT_STATUS_DESC,
+        TO_CHAR(STATUS_REVIEW_DATE, 'YYYY-MM-DD HH24:MI:SS') AS STATUS_REVIEW_DATE,
+        SUB_PROJECT_STATUS_IMPACTS,
+        SUMM_DESC,
+        REVIEWED_BY,
+        REVIEWED_BY_EMAIL
+    FROM
+       ATD_SUB_PROJECT_STATUS_VW
+    WHERE SP_NUMBER = :sp_number
+    """
+}
+
+"""
+Queries for Moped DB
+
+The upsert query is used to insert or update the eCapris statuses into the Moped DB.
+eCapris statuses can be edited within 7 days after the status review date so we need to handle updates.
+"""
+GRAPHQL_QUERIES = {
+    "subproject_statuses": """
+    query MopedProjects {
+        moped_project(where: {ecapris_subproject_id: {_is_null: false}, is_deleted: {_eq: false}}, distinct_on: ecapris_subproject_id) {
+            ecapris_subproject_id
+        }
+    }
+    """,
+    "subproject_statuses_upsert": """
+    mutation UpsertEcaprisStatuses($objects: [ecapris_subproject_statuses_insert_input!]!) {
+        insert_ecapris_subproject_statuses(objects: $objects, on_conflict: {constraint: ecapris_subproject_statuses_subproject_status_id_key, 
+        update_columns: [ecapris_subproject_id, current_status_fl, sub_project_status_desc, subproject_status_impacts, summary_description, reviewed_by_name, reviewed_by_email]}) {
+            returning {
+                subproject_status_id
+                ecapris_subproject_id
+            }
+        }
+    }
+    """,
+}
diff --git a/moped-etl/ecapris-statuses/process/request.py b/moped-etl/ecapris-statuses/process/request.py
new file mode 100644
index 0000000000..5a2f14cf17
--- /dev/null
+++ b/moped-etl/ecapris-statuses/process/request.py
@@ -0,0 +1,36 @@
+#
+# Request Helper - Makes requests to a Hasura/GraphQL endpoint
+#
+
+import os
+import requests
+
+HASURA_ENDPOINT = os.getenv("HASURA_ENDPOINT")
+HASURA_ADMIN_SECRET = os.getenv("HASURA_ADMIN_SECRET")
+
+
+def make_hasura_request(*, query, variables=None):
+    """Fetch data from hasura
+
+    Args:
+        query (str): the hasura query
+        variables (dict, optional): the query variables
+
+    Raises:
+        ValueError: If no data is returned
+
+    Returns:
+        dict: Hasura JSON response data
+    """
+    headers = {
+        "X-Hasura-Admin-Secret": HASURA_ADMIN_SECRET,
+        "content-type": "application/json",
+    }
+    payload = {"query": query, "variables": variables}
+    res = requests.post(HASURA_ENDPOINT, json=payload, headers=headers)
+    res.raise_for_status()
+    data = res.json()
+    try:
+        return data["data"]
+    except KeyError:
+        raise ValueError(data)
diff --git a/moped-etl/ecapris-statuses/process/time.py b/moped-etl/ecapris-statuses/process/time.py
new file mode 100644
index 0000000000..7a6c13dc82
--- /dev/null
+++ b/moped-etl/ecapris-statuses/process/time.py
@@ -0,0 +1,28 @@
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo
+
+
+def convert_to_timezone_aware_timestamp(date_str):
+    """
+    Converts a timestamp string in the format 'YYYY-MM-DD HH:MM:SS' to a timezone-aware
+    timestamp in the 'America/Chicago' timezone.
+
+    Args:
+        date_str (str): The timestamp string to convert, in the format 'YYYY-MM-DD HH:MM:SS'.
+
+    Returns:
+        str: The ISO timestamp string in the 'America/Chicago' timezone.
+    """
+    # Update the date format to match your input without milliseconds
+    date_format = "%Y-%m-%d %H:%M:%S"
+
+    # Parse the timestamp string
+    parsed_date = datetime.strptime(date_str, date_format)
+
+    # Convert to timezone-aware by attaching the America/Chicago timezone
+    parsed_date_tz = parsed_date.replace(tzinfo=ZoneInfo("America/Chicago"))
+
+    # Convert to ISO format
+    timestamptz = parsed_date_tz.isoformat()
+
+    return timestamptz
diff --git a/moped-etl/ecapris-statuses/requirements.txt b/moped-etl/ecapris-statuses/requirements.txt
new file mode 100644
index 0000000000..04d7b4f86d
--- /dev/null
+++ b/moped-etl/ecapris-statuses/requirements.txt
@@ -0,0 +1,2 @@
+oracledb==2.1.*
+requests==2.*


Reviews:
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T17:59:58Z
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T18:48:57Z
user: login: johnclary
body: Looks really great and the tests steps were a breeze 🚢 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T17:06:46Z
user: login: chiaberry
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T15:35:47Z
user: login: chiaberry
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T15:37:19Z
user: login: chiaberry
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T15:43:37Z
user: login: chiaberry
body: Your test instructions are the best. ✨ 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-14T15:45:02Z
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T16:13:54Z
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T16:14:44Z
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T16:15:42Z
user: login: johnclary
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T16:18:30Z
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T16:18:59Z
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T22:31:25Z
user: login: johnclary
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T17:14:57Z
user: login: mddilley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T16:55:30Z
user: login: johnclary
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T17:46:14Z

Review comments:
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: One topic that I'd love feedback on is how to mix the eCapris statuses which have columns that are mappable to the Moped note/status schema together with Moped notes/statuses. They need to be shown in the front end in the same timeline and be filterable to show each type individually in the app (Moped internal notes, Moped status updates, eCapris status updates).

I'd imagined this happening in the front end code using GraphQL aliases and a memo or similar to merge the records together, but I could be convinced to create a view that would handle this for us. Could be a cleaner way to fetch records that are all displayed together in the app. 🤔
created_at: 2025-05-12T17:59:57Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,67 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    created_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+    updated_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE
+);
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: We ran into a similar question with EMS and AFD incidents in the VZDB about whether the `updated_` audit fields should exist. In VZ, we are using them now with the crashing matching process, but I don't see these ever changing values after a row is inserted.

Do y'all think we should remove? Or is the context of knowing that they haven't been updated useful? We could also leave them in case one day eCapris introduces a way to edit existing status updates, and we have to implement upserts.

Also thinking about this [Life Altering PostgreSQL Patterns](https://mccue.dev/pages/3-11-25-life-altering-postgresql-patterns) article that @frankhereford recently shared.
created_at: 2025-05-12T18:48:57Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: i assume this is a quirk of the data/view that you've been provided, but just wanted to double check that this column should not be `reviewed_by_email`
created_at: 2025-05-13T16:41:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,67 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    created_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+    updated_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE
+);
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: Yes, agree. I've never regretted having audit fields, and often end up regretting it when I don't 🚢 
created_at: 2025-05-13T16:42:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    created_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+    updated_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE public.ecapris_subproject_statuses IS 'Stores eCAPRIS subproject status records synced from the FSD Data Warehouse to supplement the moped_proj_notes table records.';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.id IS 'Primary key for the table';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_id IS 'eCapris subproject ID number';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_name IS 'Name of eCapris subproject';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_id IS 'Unique ID of subproject status from eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.current_status_fl IS 'Is this the current and most recent status?';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.sub_project_status_desc IS 'Content of the subproject status';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.review_timestamp IS 'Timestamp of the status update - MM/DD/YYYY HH:MM:SS format';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_impacts IS 'Updates on project blockers';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.summary_description IS 'More of a public-ready status could be used down the road; nullable in eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.reviewed_by_name IS 'First and last name of author; nullable in eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.review_by_email IS 'Email of author';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.created_at IS 'Timestamp when the record was created';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.updated_at IS 'Timestamp when the record was last updated';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.created_by_user_id IS 'ID of the user who created the record';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.updated_by_user_id IS 'ID of the user who updated the record';
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: 🤩 
created_at: 2025-05-13T16:43:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: iirc, the FSD folks make a distinction between subproject "number" and subproject "id". Where "number" is the value we use in moped, which is a string concat of two pieces of data in the DB (e.g., `1234.056`). The real subproject "id" an actual integer sequence that lives in the FSD DB. 

i have the faintest memory that the subproject number is something that could possibly change over time, e.g. if a subproject were moved under a different ecapris parent project. it might be good to double check with Bruce on this, although even if we were bringing in the real integer subproject IDs, i'm not sure what good it would do us since the user-entered eCapris ID on the `moped_project` table will always be the `1234.056` flavor.
created_at: 2025-05-13T16:51:30Z
author_association: MEMBER
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: I recommend using a DB view for merging the two status tables together. Doing this upstream will keep the front-end cleaner and future proof it if there end up being multiple parts of the app (or another ETL) that need this data. We did this in VZ to merge all the change log tables together, and it has worked well 👍 
created_at: 2025-05-13T16:56:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: I like this concept and the idea of flipping a UI toggle to view eCapris statuses, but wanted to clarify the intent—"synced with eCapris" in this case means pulling eCapris statuses into Moped via ETL, right? 

If this flag determines which projects get updates pulled into Moped, I do wonder if it would be simpler/better to fetch all statuses for all projects with an eCapris ID, instead of having an on/off switch.

 If the data processing load is trivial, it would be nice if the user didn't have to take any action to enable the sync. And if we do have concerns about ETL-ing more data than we want, maybe it would be nice to have this status default to `true`?





created_at: 2025-05-13T17:05:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: chiaberry
body: There was a discussion during MUG yesterday about bringing in statuses that to be honest I got a little lost in trying to keep track of who wanted the statuses and who didnt. 

I can totally see why some projects dont need the ecapris statuses displayed, but John's comment here is making me think about pulling in all of the statuses, but the toggle button deciding whether or not to show the statuses in the UI. 

Because if someone opts in to pulling the statuses, and then changes their mind, under this setup, we would still keep the statuses in our db, right? 
created_at: 2025-05-14T15:35:46Z
author_association: MEMBER
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: chiaberry
body: John has swayed me to team DB view 
created_at: 2025-05-14T15:37:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    created_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+    updated_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE public.ecapris_subproject_statuses IS 'Stores eCAPRIS subproject status records synced from the FSD Data Warehouse to supplement the moped_proj_notes table records.';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.id IS 'Primary key for the table';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_id IS 'eCapris subproject ID number';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_name IS 'Name of eCapris subproject';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_id IS 'Unique ID of subproject status from eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.current_status_fl IS 'Is this the current and most recent status?';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.sub_project_status_desc IS 'Content of the subproject status';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.review_timestamp IS 'Timestamp of the status update - MM/DD/YYYY HH:MM:SS format';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_impacts IS 'Updates on project blockers';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.summary_description IS 'More of a public-ready status could be used down the road; nullable in eCapris';
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: chiaberry
body: this summary description is a public ready status that we wont use now, but could use later?
created_at: 2025-05-14T15:43:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: Thanks y'all! @johnclary your feedback got some thoughts churning in my mind yesterday, and, in MUG, I proposed launching this with the toggle enabled by default instead of requiring people to opt-in. Here are some points that I brought up: 
- More eyes on this feature means more feedback faster and catching potential bugs
- More eCapris statuses synced at launch
- No change in effort to implement; if it creates noise, toggling off will immediately hide eCapris statuses

@chiaberry yes, this setup will continuously collect new statuses for all eCapris subproject IDs associated with projects that have syncing enabled.

I definitely am reading from MUG that they want control on the project level whether these statuses show or not and the intent of the toggle is to pull in statuses currently available and subscribe to future statuses entered in the eCapris subprojects.
created_at: 2025-05-14T16:13:54Z
author_association: COLLABORATOR
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: me too!
created_at: 2025-05-14T16:14:44Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: yes, it is a quirk of the FSD view 🙃
created_at: 2025-05-14T16:15:42Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: Thanks for clarifying that—and yes exactly—it would be cool if the syncing was always on and the user/PM simply controlled visibility of eCapris statuses (in Moped and presumably also downstream in AGOL). 
created_at: 2025-05-14T16:18:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+    created_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+    updated_by_user_id INTEGER REFERENCES moped_users (user_id) ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE public.ecapris_subproject_statuses IS 'Stores eCAPRIS subproject status records synced from the FSD Data Warehouse to supplement the moped_proj_notes table records.';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.id IS 'Primary key for the table';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_id IS 'eCapris subproject ID number';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_name IS 'Name of eCapris subproject';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_id IS 'Unique ID of subproject status from eCapris';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.current_status_fl IS 'Is this the current and most recent status?';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.sub_project_status_desc IS 'Content of the subproject status';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.review_timestamp IS 'Timestamp of the status update - MM/DD/YYYY HH:MM:SS format';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.subproject_status_impacts IS 'Updates on project blockers';
+COMMENT ON COLUMN public.ecapris_subproject_statuses.summary_description IS 'More of a public-ready status could be used down the road; nullable in eCapris';
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: Yes, I wanted to be greedy with the FSD view since it takes some time to coordinate with FSD on new views and updates to existing ones. There is no current use for this column in Moped.
created_at: 2025-05-14T16:18:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: turns out this was a quirk of my fingers when typing. I ran into the accurate column name while working on the ETL today. I'm updating in here so they'll match up. Apologies to FSD and their view 😅 Just pushed an update to fix. 🧰
created_at: 2025-05-15T22:31:25Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
+    subproject_name TEXT NOT NULL,
+    subproject_status_id INTEGER NOT NULL UNIQUE,
+    current_status_fl BOOLEAN NOT NULL,
+    sub_project_status_desc TEXT,
+    review_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
+    subproject_status_impacts TEXT,
+    summary_description TEXT,
+    reviewed_by_name TEXT NOT NULL,
+    review_by_email TEXT,
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: Oh nice!
created_at: 2025-05-16T17:14:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: mddilley
body: This is a good question to confirm about whether a row's eCapris number can change over time. I've already seen eCapris numbers on Moped projects that return no statuses from our FSD view.

I wonder if it would be worth updating `id` to `number` in the column names here and in the Moped project table since that important distinction could get lost in the shuffle. I'm going to think about this some more. 🤔
created_at: 2025-05-19T16:55:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+-- Update moped_project table with should_sync_ecapris_statuses column and comment
+ALTER TABLE moped_project
+ADD COLUMN should_sync_ecapris_statuses BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Add comment for the new column
+COMMENT ON COLUMN moped_project.should_sync_ecapris_statuses IS 'Indicates if project statuses should be synced with eCapris';
+
+
+-- Create ecapris_status_updates table with column comments
+CREATE TABLE public.ecapris_subproject_statuses (
+    id SERIAL PRIMARY KEY,
+    subproject_id TEXT NOT NULL,
path: moped-database/migrations/1746640451691_ecapris_status_updates/up.sql
user: login: johnclary
body: Good call—that'd be a nice detail to iron out. And it does seem reasonable to expect project managers to keep the their eCapris project numbers current in Moped to maintain the proper data linkage.
created_at: 2025-05-19T17:46:13Z
author_association: MEMBER

PR comments:
user: login: netlify[bot]
created_at: 2025-05-09T23:15:52Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *atd-moped-main* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 75bcfccd75f8a3ad3115e69c7356df99ec7cb9a3 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/atd-moped-main/deploys/682f5842cb602c0008275a0f |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-1596--atd-moped-main.netlify.app](https://deploy-preview-1596--atd-moped-main.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTE1OTYtLWF0ZC1tb3BlZC1tYWluLm5ldGxpZnkuYXBwIn0.NUMRg7Rg2qXxM1JmAwB2kar4uFUxwwkHDpDOFUPrJ8Y)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [atd-moped-main Preview](https://deploy-preview-1596--atd-moped-main.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/atd-moped-main/configuration/notifications#deploy-notifications)._
author_association: NONE
user: login: mddilley
created_at: 2025-05-12T19:13:44Z
body: @mateoclarke and all - I forgot to mention that I'll update this branch to work with the [v3 metadata changes](https://github.com/cityofaustin/atd-moped/pull/1591). I wanted to get this one out for feedback ahead of those changes.
author_association: COLLABORATOR