From e0874dad7858374ecb8305215844712d312b319e Mon Sep 17 00:00:00 2001 From: mo-zag Date: Thu, 5 Mar 2026 02:03:34 +0000 Subject: [PATCH 1/5] Add stop_datetime and expiry for notifications Introduce a stop_datetime column and support for auto-unpublishing notifications. Adds a migration to add stop_datetime, updates the Notification model with a future-date validation, a currently_active scope, and expire_past_due! (uses update_all to mark past-due items unpublished). Admin controller and form now permit and edit stop_datetime (with a clear button), and the API controller calls Notification.expire_past_due! before serving notifications. Tests and factory updated to cover the new behavior. --- .../admin/notifications_controller.rb | 15 ++++-- .../v1/notifications_controller.rb | 1 + app/models/notification.rb | 20 ++++++++ app/views/admin/notifications/index.html.haml | 2 + app/views/admin/notifications/new.html.haml | 29 +++++++++-- ...0302_add_stop_datetime_to_notifications.rb | 6 +++ spec/factories/notifications.rb | 1 + spec/models/notification_spec.rb | 50 +++++++++++++++++++ spec/requests/v1/notifications_spec.rb | 33 +++++++----- 9 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 db/migrate/20260302_add_stop_datetime_to_notifications.rb diff --git a/app/controllers/admin/notifications_controller.rb b/app/controllers/admin/notifications_controller.rb index 66aff8043..e24e5e828 100644 --- a/app/controllers/admin/notifications_controller.rb +++ b/app/controllers/admin/notifications_controller.rb @@ -22,9 +22,7 @@ def show end def create - @notification = Notification.new(summary: notification_params[:summary], - notification_message: notification_params[:notification_message], - user: current_user['email'], published: true, published_at: Time.zone.now) + @notification = create_notifications Notification.transaction do if @notification.save flash[:success] = 'Notification created successfully.' @@ -55,6 +53,15 @@ def unpublish private def notification_params - params.require(:notification).permit(:summary, :notification_message) + params.require(:notification).permit(:summary, :notification_message, :stop_datetime) + end + + def create_notifications + Notification.new(summary: notification_params[:summary], + notification_message: notification_params[:notification_message], + user: current_user['email'], + published: true, + published_at: Time.zone.now, + stop_datetime: notification_params[:stop_datetime]) end end diff --git a/app/controllers/v1/notifications_controller.rb b/app/controllers/v1/notifications_controller.rb index 07ff88a6e..2ea5d980e 100644 --- a/app/controllers/v1/notifications_controller.rb +++ b/app/controllers/v1/notifications_controller.rb @@ -2,6 +2,7 @@ class V1::NotificationsController < ApiController def index + Notification.expire_past_due! markdown_parser = Redcarpet::Markdown.new(CustomMarkdownRenderer) notifications = Notification.published.first notifications[:notification_message] = markdown_parser.render(notifications[:notification_message]) if notifications diff --git a/app/models/notification.rb b/app/models/notification.rb index b24979d60..80941e2db 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,9 +1,14 @@ class Notification < ApplicationRecord validates :summary, :notification_message, presence: true + validate :stop_datetime_in_future before_save :ensure_single_published_notification scope :published, -> { where(published: true) } + scope :currently_active, lambda { + where(published: true) + .where('stop_datetime IS NULL OR stop_datetime > ?', Time.current) + } def unpublish! self.published = false @@ -11,6 +16,15 @@ def unpublish! save! end + def self.expire_past_due! + expired = where('stop_datetime < ? AND published = ?', Time.current, true) + # We use update_all for performance to expire records in a single + # SQL query, bypassing validations for speed on every API request. + # rubocop:disable Rails/SkipsModelValidations + expired.update_all(published: false, unpublished_at: Time.zone.now) + # rubocop:enable Rails/SkipsModelValidations + end + private def ensure_single_published_notification @@ -18,4 +32,10 @@ def ensure_single_published_notification Notification.where.not(id: id).where(published: true).find_each(&:unpublish!) end + + def stop_datetime_in_future + return unless stop_datetime.present? && stop_datetime < Time.current + + errors.add(:stop_datetime, 'must be in the future') + end end diff --git a/app/views/admin/notifications/index.html.haml b/app/views/admin/notifications/index.html.haml index d369a9178..7215311a3 100644 --- a/app/views/admin/notifications/index.html.haml +++ b/app/views/admin/notifications/index.html.haml @@ -36,9 +36,11 @@ %th.govuk-table__header Header %th.govuk-table__header Published %th.govuk-table__header Unpublished + %th.govuk-table__header Date time to Unpublish %tbody.govuk-table__body - @notifications.each do |notification| %tr.govuk-table__row %td.govuk-table__cell= link_to notification.summary, admin_notification_path(notification.id) %td.govuk-table__cell= notification.published_at %td.govuk-table__cell= notification.unpublished_at + %td.govuk-table__cell= notification.stop_datetime diff --git a/app/views/admin/notifications/new.html.haml b/app/views/admin/notifications/new.html.haml index 21b0652a5..c35d48b6b 100644 --- a/app/views/admin/notifications/new.html.haml +++ b/app/views/admin/notifications/new.html.haml @@ -1,15 +1,38 @@ .govuk-grid-row .govuk-grid-column-two-thirds = link_to 'Back', admin_notifications_path, { class: 'govuk-back-link govuk-!-margin-bottom-5', title: 'Back to notifications' } - = simple_form_for [:admin, @notification] do |form| %fieldset.govuk-fieldset %legend.govuk-fieldset__legend.govuk-fieldset__legend--xl %h1.govuk-fieldset__heading Create a new notification - = form.input :summary, hide_optional: true, input_html: { value: @published_notification&.summary } - = form.input :notification_message, label: 'Notification message', hide_optional: true, wrapper: :govuk_textarea_wrapper, input_html: { rows: '10', value: @published_notification&.notification_message } + = form.input :summary, + hide_optional: true, + input_html: { value: @notification.summary || @published_notification&.summary } + + = form.input :notification_message, + label: 'Notification message', + hide_optional: true, + wrapper: :govuk_textarea_wrapper, + input_html: { rows: '10', value: @notification.notification_message || @published_notification&.notification_message } + + .govuk-form-group{class: ("govuk-form-group--error" if @notification.errors[:stop_datetime].any?)} + %label.govuk-label{ for: "notification_stop_datetime" } + Unpublish on + + - if @notification.errors[:stop_datetime].any? + %span.govuk-error-message + %span.govuk-visually-hidden Error: + = @notification.errors[:stop_datetime].join(', ') + + - current_date = @notification.stop_datetime || @published_notification&.stop_datetime + = form.datetime_local_field :stop_datetime, + class: "govuk-input", + value: current_date&.strftime("%Y-%m-%dT%H:%M") + %button.govuk-button.govuk-button--secondary{ type: "button", onclick: "document.getElementById('notification_stop_datetime').value = ''", class: "govuk-!-margin-top-1" } + Clear date + %button#markdown-preview-btn{type: "button", class: 'govuk-button'} Preview = form.button :submit, value: 'Publish Notification', data: { disable_with: "Publish Notification" } diff --git a/db/migrate/20260302_add_stop_datetime_to_notifications.rb b/db/migrate/20260302_add_stop_datetime_to_notifications.rb new file mode 100644 index 000000000..09d4e3a2b --- /dev/null +++ b/db/migrate/20260302_add_stop_datetime_to_notifications.rb @@ -0,0 +1,6 @@ +class AddStopDatetimeToNotifications < ActiveRecord::Migration[6.0] + def change + add_column :notifications, :stop_datetime, :datetime, null: true + add_index :notifications, :stop_datetime + end +end \ No newline at end of file diff --git a/spec/factories/notifications.rb b/spec/factories/notifications.rb index 465cddb96..d4d85a3b6 100644 --- a/spec/factories/notifications.rb +++ b/spec/factories/notifications.rb @@ -4,6 +4,7 @@ notification_message { 'Wear sunscreen' } published { false } published_at { Time.zone.now } + stop_datetime { nil } unpublished_at { nil } user { 'testy.mctestface@example.com' } end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index c22154658..b9ada794e 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -5,6 +5,56 @@ subject { create(:notification, published: true) } it { is_expected.to validate_presence_of(:summary) } it { is_expected.to validate_presence_of(:notification_message) } + + it 'is invalid if stop_datetime is in the past' do + notification = build(:notification, stop_datetime: 1.day.ago) + expect(notification).not_to be_valid + expect(notification.errors[:stop_datetime]).to include('must be in the future') + end + + it 'is valid if stop_datetime is in the future' do + notification = build(:notification, stop_datetime: 1.day.from_now) + expect(notification).to be_valid + end + + it 'is valid if stop_datetime is nil' do + notification = build(:notification, stop_datetime: nil) + expect(notification).to be_valid + end + end + + describe 'scopes' do + describe '.currently_active' do + it 'includes published notifications with future stop dates' do + active = create(:notification, published: true, stop_datetime: 1.hour.from_now) + expect(Notification.currently_active).to include(active) + end + + it 'includes published notifications with no stop date' do + permanent = create(:notification, published: true, stop_datetime: nil) + expect(Notification.currently_active).to include(permanent) + end + + it 'excludes notifications that have passed their stop date' do + # Use save(validate: false) to simulate an old record that was valid when created + expired = build(:notification, published: true, stop_datetime: 1.hour.ago) + expired.save(validate: false) + expect(Notification.currently_active).not_to include(expired) + end + end + end + + describe '.expire_past_due!' do + it 'updates all past-due notifications to be unpublished' do + expired = build(:notification, published: true, stop_datetime: 1.minute.ago) + expired.save(validate: false) + + Notification.expire_past_due! + + expired.reload + expect(expired.published).to be false + expect(expired.unpublished_at).to be_within(1.second).of(Time.zone.now) + end end describe '#unpublish!' do diff --git a/spec/requests/v1/notifications_spec.rb b/spec/requests/v1/notifications_spec.rb index d7094e711..63ef3f965 100644 --- a/spec/requests/v1/notifications_spec.rb +++ b/spec/requests/v1/notifications_spec.rb @@ -3,6 +3,14 @@ RSpec.describe '/v1' do let(:user) { FactoryBot.create(:user) } + # 1. Define auth_headers here so it can be reused + let(:auth_headers) do + { + 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('dxw', 'sdfhg'), + 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') + } + end + describe 'GET /v1/notifications' do it 'returns 401 if authentication needed and not provided' do ClimateControl.modify API_PASSWORD: 'sdfhg' do @@ -11,31 +19,30 @@ end end - it 'returns 500 if X-Auth-Id header missing' do - expect { get '/v1/notifications' }.to raise_error(ActionController::BadRequest) + it 'returns 401 if X-Auth-Id header missing' do + get '/v1/notifications' + expect(response.status).to eq(401) end it 'returns ok if authentication needed and provided' do ClimateControl.modify API_PASSWORD: 'sdfhg' do - get '/v1/notifications', params: {}, headers: { - HTTP_AUTHORIZATION: ActionController::HttpAuthentication::Basic.encode_credentials('dxw', 'sdfhg'), - 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') - } + get '/v1/notifications', params: {}, headers: auth_headers expect(response).to be_successful end end it 'returns the details of the current published notification' do FactoryBot.create(:notification, published: true, summary: 'Testy McTestface', -notification_message: 'The answer is 42') + notification_message: 'The answer is 42') - get '/v1/notifications', headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') } + # 2. Wrap this in ClimateControl so the server can verify the password + ClimateControl.modify API_PASSWORD: 'sdfhg' do + get '/v1/notifications', headers: auth_headers - expect(response).to be_successful - expect(json['data']) - .to have_attribute(:summary) - expect(json['data']) - .to have_attribute(:notification_message) + expect(response).to be_successful + expect(json['data']).to have_attribute(:summary).with_value('Testy McTestface') + expect(json['data']).to have_attribute(:notification_message) + end end end end From bedb9b0cfeeeb096977f56779bd7e528468aead9 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Thu, 5 Mar 2026 02:32:06 +0000 Subject: [PATCH 2/5] Update notifications_spec.rb --- spec/requests/v1/notifications_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/requests/v1/notifications_spec.rb b/spec/requests/v1/notifications_spec.rb index 63ef3f965..f461d99d6 100644 --- a/spec/requests/v1/notifications_spec.rb +++ b/spec/requests/v1/notifications_spec.rb @@ -35,7 +35,6 @@ FactoryBot.create(:notification, published: true, summary: 'Testy McTestface', notification_message: 'The answer is 42') - # 2. Wrap this in ClimateControl so the server can verify the password ClimateControl.modify API_PASSWORD: 'sdfhg' do get '/v1/notifications', headers: auth_headers From 75425fbf20838c3b269e5e7dedd0b9709dbdf224 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Thu, 5 Mar 2026 10:40:49 +0000 Subject: [PATCH 3/5] Update schema.rb updated spacing really as the rails is not using 8.1, plus added one new filed in notification table stop_datetime --- db/schema.rb | 144 ++++++++++++++++++++++++++------------------------- 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 9ceb4bca5..2a3df5f6c 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_10_20_131706) do +ActiveRecord::Schema[8.1].define(version: 2025_10_20_131706) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -18,23 +18,23 @@ enable_extension "uuid-ossp" create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.uuid "record_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", precision: nil, null: false + t.string "name", null: false + t.uuid "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", precision: nil, null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -47,54 +47,54 @@ create_table "agreement_framework_lots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "agreement_id", null: false - t.uuid "framework_lot_id", null: false t.datetime "created_at", precision: nil, null: false + t.uuid "framework_lot_id", null: false t.datetime "updated_at", precision: nil, null: false t.index ["agreement_id"], name: "index_agreement_framework_lots_on_agreement_id" t.index ["framework_lot_id"], name: "index_agreement_framework_lots_on_framework_lot_id" end create_table "agreements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "active", default: true t.uuid "framework_id", null: false t.uuid "supplier_id", null: false - t.boolean "active", default: true t.index ["active"], name: "index_agreements_on_active" t.index ["framework_id"], name: "index_agreements_on_framework_id" t.index ["supplier_id"], name: "index_agreements_on_supplier_id" end create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "description", null: false t.datetime "created_at", null: false + t.string "description", null: false + t.string "key", null: false t.datetime "updated_at", null: false t.index ["key"], name: "index_api_keys_on_key", unique: true end create_table "bulk_user_uploads", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "aasm_state" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "aasm_state" t.index ["aasm_state"], name: "index_bulk_user_uploads_on_aasm_state" end create_table "customer_effort_scores", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "rating", null: false t.string "comments" t.datetime "created_at", precision: nil + t.integer "rating", null: false t.uuid "user_id" t.index ["user_id"], name: "index_customer_effort_scores_on_user_id" end create_table "customers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false + t.boolean "deleted", default: false t.string "name", null: false t.string "postcode" - t.integer "urn", null: false + t.boolean "published", default: true t.integer "sector", null: false - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.boolean "deleted", default: false - t.boolean "published", default: true + t.integer "urn", null: false t.index ["name"], name: "index_customers_on_name" t.index ["postcode"], name: "index_customers_on_postcode" t.index ["sector"], name: "index_customers_on_sector" @@ -108,11 +108,11 @@ end create_table "event_store_events", id: :serial, force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false + t.text "data", null: false t.uuid "event_id", null: false t.string "event_type", null: false t.text "metadata" - t.text "data", null: false - t.datetime "created_at", precision: nil, null: false t.datetime "valid_at" t.index ["created_at"], name: "index_event_store_events_on_created_at" t.index ["event_id"], name: "index_event_store_events_on_event_id", unique: true @@ -120,39 +120,39 @@ end create_table "event_store_events_in_streams", id: :serial, force: :cascade do |t| - t.string "stream", null: false - t.integer "position" - t.uuid "event_id", null: false t.datetime "created_at", precision: nil, null: false + t.uuid "event_id", null: false + t.integer "position" + t.string "stream", null: false t.index ["created_at"], name: "index_event_store_events_in_streams_on_created_at" t.index ["stream", "event_id"], name: "index_event_store_events_in_streams_on_stream_and_event_id", unique: true t.index ["stream", "position"], name: "index_event_store_events_in_streams_on_stream_and_position", unique: true end create_table "framework_lots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false + t.string "description" t.uuid "framework_id", null: false t.string "number", null: false - t.string "description" - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.index ["framework_id", "number"], name: "index_framework_lots_on_framework_id_and_number", unique: true t.index ["framework_id"], name: "index_framework_lots_on_framework_id" end create_table "frameworks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "aasm_state", default: "new", null: false + t.text "definition_source", null: false t.string "name" t.string "short_name", null: false - t.text "definition_source", null: false - t.string "aasm_state", default: "new", null: false t.index ["aasm_state"], name: "index_frameworks_on_aasm_state" t.index ["short_name"], name: "index_frameworks_on_short_name", unique: true end create_table "memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.uuid "supplier_id", null: false t.datetime "created_at", precision: nil, null: false + t.uuid "supplier_id", null: false t.datetime "updated_at", precision: nil, null: false + t.uuid "user_id", null: false t.index ["supplier_id"], name: "index_memberships_on_supplier_id" t.index ["user_id"], name: "index_memberships_on_user_id" end @@ -161,34 +161,36 @@ t.text "notification_message" t.boolean "published", default: false t.datetime "published_at" + t.datetime "stop_datetime", precision: nil + t.text "summary", null: false t.datetime "unpublished_at" t.string "user" - t.text "summary", null: false t.index ["published"], name: "index_notifications_on_published" + t.index ["stop_datetime"], name: "index_notifications_on_stop_datetime" end create_table "release_notes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "header", null: false t.text "body", null: false - t.boolean "published", default: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.text "header", null: false + t.boolean "published", default: false t.datetime "published_at" + t.datetime "updated_at", null: false end create_table "submission_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.uuid "submission_file_id" - t.jsonb "source" - t.jsonb "data" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.string "aasm_state" - t.jsonb "validation_errors" + t.datetime "created_at", precision: nil, null: false + t.integer "customer_urn" + t.jsonb "data" t.string "entry_type" - t.decimal "total_value" t.decimal "management_charge", precision: 18, scale: 4 - t.integer "customer_urn" + t.jsonb "source" + t.uuid "submission_file_id" + t.uuid "submission_id", null: false + t.decimal "total_value" + t.datetime "updated_at", precision: nil, null: false + t.jsonb "validation_errors" t.index ["aasm_state"], name: "index_submission_entries_on_aasm_state" t.index ["entry_type"], name: "index_submission_entries_on_entry_type" t.index ["entry_type"], name: "index_submission_entries_on_invoice_entry_type", where: "((entry_type)::text = 'invoice'::text)" @@ -199,18 +201,18 @@ end create_table "submission_entries_stages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.uuid "submission_file_id" - t.jsonb "source" - t.jsonb "data" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.string "aasm_state" - t.jsonb "validation_errors" + t.datetime "created_at", precision: nil, null: false + t.integer "customer_urn" + t.jsonb "data" t.string "entry_type" - t.decimal "total_value" t.decimal "management_charge", precision: 18, scale: 4 - t.integer "customer_urn" + t.jsonb "source" + t.uuid "submission_file_id" + t.uuid "submission_id", null: false + t.decimal "total_value" + t.datetime "updated_at", precision: nil, null: false + t.jsonb "validation_errors" t.index ["aasm_state"], name: "index_submission_entries_stages_on_aasm_state" t.index ["entry_type"], name: "index_submission_entries_stage_on_invoice_entry_type", where: "((entry_type)::text = 'invoice'::text)" t.index ["entry_type"], name: "index_submission_entries_stages_on_entry_type" @@ -221,36 +223,36 @@ end create_table "submission_files", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.integer "rows" t.datetime "created_at", precision: nil, null: false + t.integer "rows" + t.uuid "submission_id", null: false t.datetime "updated_at", precision: nil, null: false t.index ["submission_id"], name: "index_submission_files_on_submission_id" end create_table "submission_invoices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.string "workday_reference" t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.boolean "reversal", default: false, null: false + t.uuid "submission_id", null: false + t.datetime "updated_at", precision: nil, null: false + t.string "workday_reference" t.index ["submission_id"], name: "index_submission_invoices_on_submission_id" end create_table "submissions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "framework_id", null: false - t.uuid "supplier_id", null: false t.string "aasm_state" - t.uuid "task_id", null: false + t.boolean "cleanup_processed", default: false, null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.string "purchase_order_number" t.uuid "created_by_id" - t.uuid "submitted_by_id" - t.datetime "submitted_at", precision: nil - t.decimal "management_charge_total", precision: 18, scale: 4 + t.uuid "framework_id", null: false t.decimal "invoice_total", precision: 18, scale: 4 - t.boolean "cleanup_processed", default: false, null: false + t.decimal "management_charge_total", precision: 18, scale: 4 + t.string "purchase_order_number" + t.datetime "submitted_at", precision: nil + t.uuid "submitted_by_id" + t.uuid "supplier_id", null: false + t.uuid "task_id", null: false + t.datetime "updated_at", precision: nil, null: false t.index ["aasm_state"], name: "index_submissions_on_aasm_state" t.index ["cleanup_processed"], name: "index_submissions_on_cleanup_processed" t.index ["created_at"], name: "index_submissions_on_created_at", order: :desc @@ -270,15 +272,15 @@ end create_table "tasks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "status", null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.string "description" t.date "due_on" + t.uuid "framework_id" t.integer "period_month" t.integer "period_year" + t.string "status", null: false t.uuid "supplier_id" - t.uuid "framework_id" + t.datetime "updated_at", precision: nil, null: false t.index ["framework_id"], name: "index_tasks_on_framework_id" t.index ["status"], name: "index_tasks_on_status" t.index ["supplier_id"], name: "index_tasks_on_supplier_id" @@ -286,17 +288,17 @@ end create_table "urn_lists", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "aasm_state" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "aasm_state" t.index ["aasm_state"], name: "index_urn_lists_on_aasm_state" end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "auth_id" - t.string "name" - t.string "email" t.datetime "created_at", precision: nil, null: false + t.string "email" + t.string "name" t.datetime "updated_at", precision: nil, null: false t.index ["auth_id"], name: "index_users_on_auth_id", unique: true end From 7bbb118bdbd70750747e72760df8e1b5b6a66320 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Thu, 5 Mar 2026 11:16:53 +0000 Subject: [PATCH 4/5] Update notifications_spec.rb --- spec/requests/v1/notifications_spec.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/requests/v1/notifications_spec.rb b/spec/requests/v1/notifications_spec.rb index f461d99d6..6873367eb 100644 --- a/spec/requests/v1/notifications_spec.rb +++ b/spec/requests/v1/notifications_spec.rb @@ -3,7 +3,6 @@ RSpec.describe '/v1' do let(:user) { FactoryBot.create(:user) } - # 1. Define auth_headers here so it can be reused let(:auth_headers) do { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('dxw', 'sdfhg'), @@ -19,9 +18,8 @@ end end - it 'returns 401 if X-Auth-Id header missing' do - get '/v1/notifications' - expect(response.status).to eq(401) + it 'returns 500 if X-Auth-Id header missing' do + expect { get '/v1/notifications' }.to raise_error(ActionController::BadRequest) end it 'returns ok if authentication needed and provided' do From 5c49bfa709823bbb337c5c4e9765e64598fc5510 Mon Sep 17 00:00:00 2001 From: Reem Ibrahim Date: Mon, 20 Apr 2026 15:26:12 +0100 Subject: [PATCH 5/5] fix for local time issue and spec --- .../admin/notifications_controller.rb | 12 +++++++-- app/views/admin/notifications/new.html.haml | 3 ++- spec/requests/admin/notifications_spec.rb | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/notifications_controller.rb b/app/controllers/admin/notifications_controller.rb index e24e5e828..b81152eb1 100644 --- a/app/controllers/admin/notifications_controller.rb +++ b/app/controllers/admin/notifications_controller.rb @@ -3,7 +3,7 @@ class Admin::NotificationsController < AdminController def index markdown_parser = Redcarpet::Markdown.new(CustomMarkdownRenderer) - @published_notification = Notification.published.first + @published_notification = Notification.currently_active.first if @published_notification @published_notification_message = markdown_parser.render(@published_notification[:notification_message]) end @@ -62,6 +62,14 @@ def create_notifications user: current_user['email'], published: true, published_at: Time.zone.now, - stop_datetime: notification_params[:stop_datetime]) + stop_datetime: parsed_stop_datetime) + end + + def parsed_stop_datetime + return nil if notification_params[:stop_datetime].blank? + + Time.use_zone('Europe/London') do + Time.zone.parse(notification_params[:stop_datetime]) + end end end diff --git a/app/views/admin/notifications/new.html.haml b/app/views/admin/notifications/new.html.haml index c35d48b6b..e771a2cae 100644 --- a/app/views/admin/notifications/new.html.haml +++ b/app/views/admin/notifications/new.html.haml @@ -27,9 +27,10 @@ = @notification.errors[:stop_datetime].join(', ') - current_date = @notification.stop_datetime || @published_notification&.stop_datetime + - current_date_london = current_date&.in_time_zone('Europe/London') = form.datetime_local_field :stop_datetime, class: "govuk-input", - value: current_date&.strftime("%Y-%m-%dT%H:%M") + value: current_date_london&.strftime("%Y-%m-%dT%H:%M") %button.govuk-button.govuk-button--secondary{ type: "button", onclick: "document.getElementById('notification_stop_datetime').value = ''", class: "govuk-!-margin-top-1" } Clear date diff --git a/spec/requests/admin/notifications_spec.rb b/spec/requests/admin/notifications_spec.rb index 7ac896f66..a4de6dbd8 100644 --- a/spec/requests/admin/notifications_spec.rb +++ b/spec/requests/admin/notifications_spec.rb @@ -9,6 +9,33 @@ get '/auth/google_oauth2/callback' end + describe '#create' do + around do |example| + travel_to(Time.utc(2026, 4, 20, 12, 0, 0)) { example.run } + end + + it 'stores stop_datetime as Europ/London local time converted to UTC' do + london_input = '2026-04-20T14:15:00' # 2:15 PM London time on April 20, 2026 + + expect do + post admin_notifications_path, params: { + notification: { + summary: 'Test Notification', + notification_message: 'This is a test notification.', + stop_datetime: london_input + } + } + end.to change(Notification, :count).by(1) + + notification = Notification.order(published_at: :desc).first + + expected_time = ActiveSupport::TimeZone['Europe/London'].parse(london_input) + + expect(notification.stop_datetime).to eq(expected_time) + expect(notification.stop_datetime.utc).to eq(expected_time.utc) + end + end + describe '#preview' do it 'renders the Markdown content as HTML' do markdown_content = '**Bold Text**'