Repository: alphagov/govuk-chat
Pull request #199: Associate conversations created by api with the current signon user
Author: login: davidgisbey
Created: 2025-05-01T15:35:26Z | Merged: 2025-05-02T10:00:30Z

## Description

We need to be able to associate conversations created by the API with the SignonUser (API user) that made the request. Down the line we suspect we will have multiple API users and want to be able to distinguish between conversations belonging to each. As well as between API users and web users.

This: 
- adds a signon_user foreign key to the conversations table
- creates the association in the models
- updates the create endpoint to associate the conversation with the current user (SignonUser)

## Out of scope 

- changes to the Admin UI

## Trello card

https://trello.com/c/3979ssqM/2447-chat-api-associate-conversations-created-by-api-with-user


Diff:
diff --git a/app/controllers/api/v0/conversations_controller.rb b/app/controllers/api/v0/conversations_controller.rb
index 7f1181f2a..c4c5f1673 100644
--- a/app/controllers/api/v0/conversations_controller.rb
+++ b/app/controllers/api/v0/conversations_controller.rb
@@ -1,9 +1,10 @@
 class Api::V0::ConversationsController < ApplicationController
+  skip_before_action :verify_authenticity_token
   before_action { authorise_user!(SignonUser::Permissions::CONVERSATION_API) }
   before_action :find_conversation, only: %i[show update answer answer_feedback]
 
   def create
-    conversation = Conversation.new
+    conversation = Conversation.new(signon_user: current_user)
     form = Form::CreateQuestion.new(question_params.merge(conversation:))
 
     if form.valid?
@@ -69,6 +70,7 @@ def answer_feedback
   def find_conversation
     @conversation = Conversation
                     .includes(questions: { answer: %i[sources feedback] })
+                    .where(signon_user_id: current_user.id)
                     .find(params[:conversation_id])
   end
 
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 60e55d1dd..efc2d5ac6 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -2,6 +2,7 @@ class Conversation < ApplicationRecord
   has_many :questions
   has_many :answers, through: :questions
   belongs_to :user, optional: true, class_name: "EarlyAccessUser", foreign_key: :early_access_user_id
+  belongs_to :signon_user, optional: true
 
   scope :active, -> { where(Question.active.where("questions.conversation_id = conversations.id").arel.exists) }
 
diff --git a/app/models/signon_user.rb b/app/models/signon_user.rb
index 218d52c74..24963b688 100644
--- a/app/models/signon_user.rb
+++ b/app/models/signon_user.rb
@@ -6,4 +6,6 @@ module Permissions
     CONVERSATION_API = "conversation-api".freeze
     DEVELOPER_TOOLS = "developer-tools".freeze
   end
+
+  has_many :conversations
 end
diff --git a/db/migrate/20250501150559_add_signon_user_foreign_key_to_conversation.rb b/db/migrate/20250501150559_add_signon_user_foreign_key_to_conversation.rb
new file mode 100644
index 000000000..c3e06040a
--- /dev/null
+++ b/db/migrate/20250501150559_add_signon_user_foreign_key_to_conversation.rb
@@ -0,0 +1,5 @@
+class AddSignonUserForeignKeyToConversation < ActiveRecord::Migration[8.0]
+  def change
+    add_reference :conversations, :signon_user, foreign_key: { null: true, on_delete: :restrict }, type: :uuid
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index aa61bd00e..fd0e71319 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[8.0].define(version: 2025_04_15_120236) do
+ActiveRecord::Schema[8.0].define(version: 2025_05_01_150559) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "citext"
   enable_extension "pg_catalog.plpgsql"
@@ -96,8 +96,10 @@
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.uuid "early_access_user_id"
+    t.uuid "signon_user_id"
     t.index ["created_at"], name: "index_conversations_on_created_at"
     t.index ["early_access_user_id"], name: "index_conversations_on_early_access_user_id"
+    t.index ["signon_user_id"], name: "index_conversations_on_signon_user_id"
   end
 
   create_table "deleted_early_access_users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -223,6 +225,7 @@
   add_foreign_key "answer_feedback", "answers", on_delete: :cascade
   add_foreign_key "answer_sources", "answers", on_delete: :cascade
   add_foreign_key "answers", "questions", on_delete: :cascade
+  add_foreign_key "conversations", "signon_users", on_delete: :restrict
   add_foreign_key "questions", "conversations"
   add_foreign_key "settings_audits", "signon_users", column: "user_id", on_delete: :nullify
 end
diff --git a/spec/requests/api/v0/conversations_spec.rb b/spec/requests/api/v0/conversations_spec.rb
index 58017c402..4c5d9ef17 100644
--- a/spec/requests/api/v0/conversations_spec.rb
+++ b/spec/requests/api/v0/conversations_spec.rb
@@ -1,9 +1,10 @@
 RSpec.describe "Api::V0::ConversationsController" do
-  let(:conversation) { create(:conversation) }
+  let(:api_user) { create(:signon_user, :conversation_api) }
+  let(:conversation) { create(:conversation, signon_user: api_user) }
   let(:question) { create(:question, conversation:) }
 
   before do
-    login_as(create(:signon_user, :conversation_api))
+    login_as(api_user)
   end
 
   shared_examples "responds with forbidden if user doesn't have conversation-api permission" do |routes:|
@@ -104,6 +105,15 @@
 
       expect(response).to have_http_status(:not_found)
     end
+
+    it "returns a 404 if the conversation is not associated with the user" do
+      different_user = create(:signon_user, :conversation_api)
+      conversation = create(:conversation, signon_user: different_user)
+
+      get api_v0_show_conversation_path(conversation)
+
+      expect(response).to have_http_status(:not_found)
+    end
   end
 
   describe "POST :create" do
@@ -127,26 +137,33 @@
         expect(JSON.parse(response.body)).to eq(expected_payload)
       end
 
-      context "when the question is invalid" do
-        let(:payload) { { user_question: "" } }
+      it "associates the conversation with the SignonUser" do
+        post api_v0_create_conversation_path, params: payload, as: :json
 
-        it "returns a 422 Unprocessable Entity status" do
-          post api_v0_create_conversation_path, params: payload, as: :json
+        conversation = Conversation.includes(:signon_user).last
+        expect(conversation.signon_user).to eq(api_user)
+      end
+    end
 
-          expect(response).to have_http_status(:unprocessable_entity)
-        end
+    context "when the question is invalid" do
+      let(:payload) { { user_question: "" } }
 
-        it "returns the correct JSON in the body" do
-          post api_v0_create_conversation_path, params: payload, as: :json
+      it "returns a 422 Unprocessable Entity status" do
+        post api_v0_create_conversation_path, params: payload, as: :json
 
-          expect(JSON.parse(response.body))
-            .to eq(
-              {
-                "message" => "Unprocessable entity",
-                "errors" => { "user_question" => [Form::CreateQuestion::USER_QUESTION_PRESENCE_ERROR_MESSAGE] },
-              },
-            )
-        end
+        expect(response).to have_http_status(:unprocessable_entity)
+      end
+
+      it "returns the correct JSON in the body" do
+        post api_v0_create_conversation_path, params: payload, as: :json
+
+        expect(JSON.parse(response.body))
+          .to eq(
+            {
+              "message" => "Unprocessable entity",
+              "errors" => { "user_question" => [Form::CreateQuestion::USER_QUESTION_PRESENCE_ERROR_MESSAGE] },
+            },
+          )
       end
     end
   end


Reviews:
user: login: kevindew
body: Looks good, just a couple of considerations that we should look at.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T08:11:19Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T08:13:19Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T08:20:30Z
user: login: kevindew
body: Nice one
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T08:50:21Z

Review comments:
diff_hunk: @@ -0,0 +1,5 @@
+class AddSignonUserForeignKeyToConversation < ActiveRecord::Migration[8.0]
+  def change
+    add_reference :conversations, :signon_user, foreign_key: { null: true, on_delete: :nullify }, type: :uuid
path: db/migrate/20250501150559_add_signon_user_foreign_key_to_conversation.rb
user: login: kevindew
body: on delete nullify seems a little fishy to me given we'd just lose history. To avoid boiling the ocean here I think going for `on_delete: restrict` as an interim measure would be prudent. 

I imagine a potential future option is that we don't have an actual foreign key and just retain the id if a signon user is deleted, or we can cope with it being null as we have additional metadata that tells us where the request came from.
created_at: 2025-05-02T08:09:11Z
author_association: MEMBER
diff_hunk: @@ -3,7 +3,7 @@ class Api::V0::ConversationsController < ApplicationController
   before_action :find_conversation, only: %i[show update answer answer_feedback]
 
   def create
-    conversation = Conversation.new
+    conversation = Conversation.new(signon_user: current_user)
path: app/controllers/api/v0/conversations_controller.rb
user: login: kevindew
body: I imagine we also want this to be part of the where query for looking up a conversation. As, with the code currently, we'd allow any signon user to carry on the conversation or look up aspects of it.
created_at: 2025-05-02T08:10:31Z
author_association: MEMBER
diff_hunk: @@ -3,7 +3,7 @@ class Api::V0::ConversationsController < ApplicationController
   before_action :find_conversation, only: %i[show update answer answer_feedback]
 
   def create
-    conversation = Conversation.new
+    conversation = Conversation.new(signon_user: current_user)
path: app/controllers/api/v0/conversations_controller.rb
user: login: davidgisbey
body: ah great catch 👍 Will sort 
created_at: 2025-05-02T08:13:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+class AddSignonUserForeignKeyToConversation < ActiveRecord::Migration[8.0]
+  def change
+    add_reference :conversations, :signon_user, foreign_key: { null: true, on_delete: :nullify }, type: :uuid
path: db/migrate/20250501150559_add_signon_user_foreign_key_to_conversation.rb
user: login: davidgisbey
body: Yea i was bouncing between which one to use but restrict makes sense to me. Realistically i can't see a reason the user would be deleted from the db and the convos wouldn't also be, so i'm not opposed to just retaining the ID. 

I'll update it to restrict for now though 
created_at: 2025-05-02T08:20:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,6 +105,15 @@
 
       expect(response).to have_http_status(:not_found)
     end
+
+    it "returns a 404 if the conversation is not associated with the user" do
path: spec/requests/api/v0/conversations_spec.rb
user: login: kevindew
body: Looks good - I imagine a potential semantic improvement could be changing this to be a 403, but it doesn't seem worth it now with the expectation of so few clients.
created_at: 2025-05-02T08:50:14Z
author_association: MEMBER

PR comments:
user: login: davidgisbey
created_at: 2025-05-02T08:36:40Z
body: @kevindew thanks for that. I've made those changes 👍 
author_association: CONTRIBUTOR
user: login: davidgisbey
created_at: 2025-05-02T09:36:04Z
body: Thanks Kev. Sorry just so you're aware i pushed up one [final commit](https://github.com/alphagov/govuk-chat/pull/199/commits/b0bcb9f46f7b2a871d106aca13e4fa8f2cafb797) which skips `:verify_authenticity_token`. 

When i was manually testing the endpoints on integration i ran into 422s due to the token not being present.
author_association: CONTRIBUTOR
user: login: kevindew
created_at: 2025-05-02T10:20:13Z
body: Yup good shout on the CSRF token - something for a potential api base controller
author_association: MEMBER