consentful 0.1.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/consentful/application.css +15 -0
- data/app/assets/stylesheets/consentful/consent.scss +518 -0
- data/app/components/consentful/banner.html.erb +53 -0
- data/app/components/consentful/banner.rb +27 -0
- data/app/components/consentful/modal.html.erb +19 -0
- data/app/components/consentful/modal.rb +21 -0
- data/app/components/consentful/preferences.html.erb +167 -0
- data/app/components/consentful/preferences.rb +48 -0
- data/app/components/consentful/service_detail.html.erb +99 -0
- data/app/components/consentful/service_detail.rb +18 -0
- data/app/components/consentful/toggle.html.erb +30 -0
- data/app/components/consentful/toggle.rb +30 -0
- data/app/controllers/concerns/consentful/visitor_cookie.rb +52 -0
- data/app/controllers/consentful/admin/base_controller.rb +27 -0
- data/app/controllers/consentful/admin/categories_controller.rb +46 -0
- data/app/controllers/consentful/admin/copy_versions_controller.rb +113 -0
- data/app/controllers/consentful/admin/dashboard_controller.rb +11 -0
- data/app/controllers/consentful/admin/records_controller.rb +11 -0
- data/app/controllers/consentful/admin/services_controller.rb +80 -0
- data/app/controllers/consentful/application_controller.rb +9 -0
- data/app/controllers/consentful/consent_controller.rb +132 -0
- data/app/helpers/consentful/application_helper.rb +4 -0
- data/app/helpers/consentful/consent_helper.rb +23 -0
- data/app/javascript/consentful/controllers/banner_controller.js +92 -0
- data/app/javascript/consentful/controllers/clipboard_copy_controller.js +30 -0
- data/app/javascript/consentful/controllers/modal_controller.js +50 -0
- data/app/javascript/consentful/controllers/modal_trigger_controller.js +9 -0
- data/app/javascript/consentful/controllers/preferences_controller.js +182 -0
- data/app/javascript/consentful/index.js +42 -0
- data/app/javascript/consentful/state.js +91 -0
- data/app/jobs/consentful/application_job.rb +4 -0
- data/app/mailers/consentful/application_mailer.rb +6 -0
- data/app/models/consentful/application_record.rb +8 -0
- data/app/models/consentful/consent_category.rb +21 -0
- data/app/models/consentful/consent_copy_version.rb +49 -0
- data/app/models/consentful/consent_record.rb +33 -0
- data/app/models/consentful/consent_service.rb +38 -0
- data/app/models/consentful/consent_state.rb +80 -0
- data/app/models/consentful/consent_visitor_link.rb +26 -0
- data/app/presenters/consentful/consent_record_presenter.rb +73 -0
- data/app/services/consentful/dashboard_report.rb +45 -0
- data/app/services/consentful/sar_lookup.rb +47 -0
- data/app/views/consentful/admin/categories/edit.html.erb +22 -0
- data/app/views/consentful/admin/categories/index.html.erb +17 -0
- data/app/views/consentful/admin/copy_versions/_form.html.erb +34 -0
- data/app/views/consentful/admin/copy_versions/edit.html.erb +2 -0
- data/app/views/consentful/admin/copy_versions/index.html.erb +34 -0
- data/app/views/consentful/admin/copy_versions/new.html.erb +2 -0
- data/app/views/consentful/admin/copy_versions/show.html.erb +24 -0
- data/app/views/consentful/admin/dashboard/show.html.erb +35 -0
- data/app/views/consentful/admin/records/index.html.erb +35 -0
- data/app/views/consentful/admin/services/_form.html.erb +42 -0
- data/app/views/consentful/admin/services/edit.html.erb +2 -0
- data/app/views/consentful/admin/services/index.html.erb +27 -0
- data/app/views/consentful/admin/services/new.html.erb +2 -0
- data/app/views/consentful/consent/preferences.html.erb +14 -0
- data/app/views/layouts/consentful/admin.html.erb +66 -0
- data/app/views/layouts/consentful/application.html.erb +17 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260522100000_create_consent_categories.rb +19 -0
- data/db/migrate/20260522100100_create_consent_services.rb +38 -0
- data/db/migrate/20260522100200_create_consent_copy_versions.rb +30 -0
- data/db/migrate/20260522100300_create_consent_records.rb +22 -0
- data/db/migrate/20260522100400_create_consent_visitor_links.rb +21 -0
- data/lib/consentful/configuration.rb +100 -0
- data/lib/consentful/engine.rb +5 -0
- data/lib/consentful/seed_starter.rb +57 -0
- data/lib/consentful/version.rb +3 -0
- data/lib/consentful.rb +27 -0
- data/lib/generators/consentful/install_generator.rb +29 -0
- data/lib/generators/consentful/templates/consentful.rb +35 -0
- data/lib/tasks/consentful_tasks.rake +9 -0
- metadata +149 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import Cookies from 'js-cookie';
|
|
2
|
+
|
|
3
|
+
// Cookie name is configurable so it matches the host's Consentful.config.cookie_name.
|
|
4
|
+
// Call configureConsent({ cookieName }) once at boot (registerConsentful does this).
|
|
5
|
+
let cookieName = 'consent';
|
|
6
|
+
const EXPIRY_MONTHS = 12;
|
|
7
|
+
const EMPTY_STATE = Object.freeze({
|
|
8
|
+
version_id: null,
|
|
9
|
+
categories: {},
|
|
10
|
+
services: {},
|
|
11
|
+
recorded_at: null,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function configureConsent({ cookieName: name } = {}) {
|
|
15
|
+
if (name) cookieName = name;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readConsent() {
|
|
19
|
+
const raw = Cookies.get(cookieName);
|
|
20
|
+
if (!raw) return { ...EMPTY_STATE };
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
return {
|
|
25
|
+
version_id: parsed.version_id ?? null,
|
|
26
|
+
categories: parsed.categories ?? {},
|
|
27
|
+
services: parsed.services ?? {},
|
|
28
|
+
recorded_at: parsed.recorded_at ?? null,
|
|
29
|
+
};
|
|
30
|
+
} catch (_err) {
|
|
31
|
+
return { ...EMPTY_STATE };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isExpired(state) {
|
|
36
|
+
if (!state.recorded_at) return true;
|
|
37
|
+
const recordedMs = state.recorded_at * 1000;
|
|
38
|
+
const expiryMs = EXPIRY_MONTHS * 30 * 24 * 60 * 60 * 1000;
|
|
39
|
+
return Date.now() - recordedMs > expiryMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function matchesVersion(state, expectedVersionId) {
|
|
43
|
+
if (!expectedVersionId || !state.version_id) return false;
|
|
44
|
+
return Number(state.version_id) === Number(expectedVersionId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function shouldShowBanner(state, expectedVersionId) {
|
|
48
|
+
if (!state.recorded_at) return true;
|
|
49
|
+
if (isExpired(state)) return true;
|
|
50
|
+
if (!matchesVersion(state, expectedVersionId)) return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isServiceAllowed(state, { slug, categoryKey, categoryRequired }) {
|
|
55
|
+
if (categoryRequired) return true;
|
|
56
|
+
if (Object.prototype.hasOwnProperty.call(state.services, slug)) {
|
|
57
|
+
return Boolean(state.services[slug]);
|
|
58
|
+
}
|
|
59
|
+
return Boolean(state.categories[categoryKey]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function applyConsent(state, categoryGtagKeys = {}) {
|
|
63
|
+
if (typeof window === 'undefined') return;
|
|
64
|
+
|
|
65
|
+
window.dataLayer = window.dataLayer || [];
|
|
66
|
+
window.dataLayer.push({
|
|
67
|
+
event: 'consent_update',
|
|
68
|
+
consent: {
|
|
69
|
+
version_id: state.version_id,
|
|
70
|
+
categories: state.categories,
|
|
71
|
+
services: state.services,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (typeof window.gtag === 'function' && Object.keys(categoryGtagKeys).length > 0) {
|
|
76
|
+
const update = {};
|
|
77
|
+
for (const [categoryKey, signals] of Object.entries(categoryGtagKeys)) {
|
|
78
|
+
const allowed = state.categories[categoryKey] ? 'granted' : 'denied';
|
|
79
|
+
for (const signal of signals) update[signal] = allowed;
|
|
80
|
+
}
|
|
81
|
+
window.gtag('consent', 'update', update);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function writeOptimistic(state) {
|
|
86
|
+
Cookies.set(cookieName, JSON.stringify(state), {
|
|
87
|
+
expires: EXPIRY_MONTHS * 30,
|
|
88
|
+
sameSite: 'lax',
|
|
89
|
+
secure: window.location.protocol === 'https:',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
module Consentful
|
|
2
|
+
# Engine base class. Deliberately inherits straight from ActiveRecord::Base
|
|
3
|
+
# rather than the host's ApplicationRecord, which may wire primary/replica
|
|
4
|
+
# connection switching the engine should not inherit.
|
|
5
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
6
|
+
self.abstract_class = true
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
class ConsentCategory < ApplicationRecord
|
|
5
|
+
# Unprefixed table name (isolate_namespace would otherwise expect
|
|
6
|
+
# `consentful_consent_categories`); keeps parity with an extracting host.
|
|
7
|
+
self.table_name = "consent_categories"
|
|
8
|
+
|
|
9
|
+
has_many :consent_services, dependent: :restrict_with_error
|
|
10
|
+
|
|
11
|
+
validates :key, presence: true, uniqueness: true
|
|
12
|
+
validates :key,
|
|
13
|
+
inclusion: { in: ->(_) { Consentful.config.category_keys } },
|
|
14
|
+
if: -> { Consentful.config.category_keys.present? }
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validates :position, presence: true, numericality: { only_integer: true }
|
|
17
|
+
|
|
18
|
+
default_scope { order(position: :asc) }
|
|
19
|
+
scope :required_categories, -> { where(required: true) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Consentful
|
|
6
|
+
class ConsentCopyVersion < ApplicationRecord
|
|
7
|
+
self.table_name = "consent_copy_versions"
|
|
8
|
+
|
|
9
|
+
STATUSES = { draft: 0, published: 1, archived: 2 }.freeze
|
|
10
|
+
|
|
11
|
+
has_many :consent_records, dependent: :restrict_with_error
|
|
12
|
+
|
|
13
|
+
enum :status, STATUSES
|
|
14
|
+
|
|
15
|
+
validates :digest, presence: true
|
|
16
|
+
|
|
17
|
+
before_validation :recalculate_digest
|
|
18
|
+
|
|
19
|
+
default_scope { order(created_at: :desc) }
|
|
20
|
+
|
|
21
|
+
def self.current
|
|
22
|
+
published.first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# `published_by` is a free-form identity string (e.g. an admin email)
|
|
26
|
+
# resolved by the host via Consentful.config.identify — not an FK.
|
|
27
|
+
def publish!(published_by = nil)
|
|
28
|
+
transaction do
|
|
29
|
+
self.class.published.where.not(id:).find_each { |v| v.update!(status: :archived) }
|
|
30
|
+
update!(status: :published, published_at: Time.current, published_by:)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def archive!
|
|
35
|
+
update!(status: :archived)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def recalculate_digest
|
|
41
|
+
payload = [
|
|
42
|
+
banner_title, banner_body, modal_intro,
|
|
43
|
+
accept_label, reject_label, manage_label, save_label,
|
|
44
|
+
category_blurbs.to_json
|
|
45
|
+
].join("|")
|
|
46
|
+
self.digest = Digest::SHA256.hexdigest(payload)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
class ConsentRecord < ApplicationRecord
|
|
5
|
+
self.table_name = "consent_records"
|
|
6
|
+
|
|
7
|
+
SOURCES = {
|
|
8
|
+
banner_accept_all: 0,
|
|
9
|
+
banner_reject_all: 1,
|
|
10
|
+
banner_save: 2,
|
|
11
|
+
preference_centre: 3,
|
|
12
|
+
expiry_reprompt: 4
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
belongs_to :consent_copy_version
|
|
16
|
+
|
|
17
|
+
enum :source, SOURCES
|
|
18
|
+
|
|
19
|
+
validates :visitor_uuid, presence: true
|
|
20
|
+
validates :recorded_at, presence: true
|
|
21
|
+
|
|
22
|
+
before_validation :default_recorded_at, on: :create
|
|
23
|
+
|
|
24
|
+
scope :for_visitor, ->(uuid) { where(visitor_uuid: uuid) }
|
|
25
|
+
scope :within, ->(range) { where(recorded_at: range) }
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def default_recorded_at
|
|
30
|
+
self.recorded_at ||= Time.current
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
class ConsentService < ApplicationRecord
|
|
5
|
+
self.table_name = "consent_services"
|
|
6
|
+
|
|
7
|
+
LOADER_KINDS = { rails_partial: 0, gtm_tag: 1, inline_view: 2, external_only: 3 }.freeze
|
|
8
|
+
|
|
9
|
+
LOADER_KIND_LABELS = {
|
|
10
|
+
"rails_partial" => "Site-wide tag (managed in code)",
|
|
11
|
+
"gtm_tag" => "Google Tag Manager",
|
|
12
|
+
"inline_view" => "Single-page tag",
|
|
13
|
+
"external_only" => "External resource (no tag)"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
REQUIRED_DISCLOSURE_FIELDS = %i[description processing_company_name legal_basis retention_period processing_location].freeze
|
|
17
|
+
|
|
18
|
+
belongs_to :consent_category
|
|
19
|
+
|
|
20
|
+
enum :loader_kind, LOADER_KINDS
|
|
21
|
+
|
|
22
|
+
validates :name, presence: true
|
|
23
|
+
validates :slug, presence: true, uniqueness: true,
|
|
24
|
+
format: { with: /\A[a-z0-9_]+\z/, message: "may only contain lowercase letters, digits and underscores" }
|
|
25
|
+
validates :position, presence: true, numericality: { only_integer: true }
|
|
26
|
+
|
|
27
|
+
scope :active, -> { where(active: true) }
|
|
28
|
+
default_scope { order(:position, :name) }
|
|
29
|
+
|
|
30
|
+
def loader_kind_label
|
|
31
|
+
LOADER_KIND_LABELS.fetch(loader_kind, loader_kind.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def disclosure_complete?
|
|
35
|
+
REQUIRED_DISCLOSURE_FIELDS.all? { |f| public_send(f).present? }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
# Plain value object over the consent cookie payload. Mirrors the JS
|
|
5
|
+
# implementation in the engine's state.js — keep the two in lockstep.
|
|
6
|
+
class ConsentState
|
|
7
|
+
EXPIRY = 12.months
|
|
8
|
+
EMPTY_COOKIE = {
|
|
9
|
+
"version_id" => nil,
|
|
10
|
+
"categories" => {},
|
|
11
|
+
"services" => {},
|
|
12
|
+
"recorded_at" => nil
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :version_id, :categories, :services, :recorded_at
|
|
16
|
+
|
|
17
|
+
def self.from_cookie(raw)
|
|
18
|
+
payload = parse(raw)
|
|
19
|
+
new(
|
|
20
|
+
version_id: payload["version_id"],
|
|
21
|
+
categories: payload["categories"] || {},
|
|
22
|
+
services: payload["services"] || {},
|
|
23
|
+
recorded_at: payload["recorded_at"]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.empty
|
|
28
|
+
new(version_id: nil, categories: {}, services: {}, recorded_at: nil)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.parse(raw)
|
|
32
|
+
return EMPTY_COOKIE if raw.blank?
|
|
33
|
+
|
|
34
|
+
JSON.parse(raw)
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
EMPTY_COOKIE
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(version_id:, categories:, services:, recorded_at:)
|
|
40
|
+
@version_id = version_id
|
|
41
|
+
@categories = (categories || {}).transform_keys(&:to_s)
|
|
42
|
+
@services = (services || {}).transform_keys(&:to_s)
|
|
43
|
+
@recorded_at = recorded_at
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def allowed?(service)
|
|
47
|
+
return true if service.consent_category&.required?
|
|
48
|
+
|
|
49
|
+
override = @services[service.slug]
|
|
50
|
+
return override unless override.nil?
|
|
51
|
+
|
|
52
|
+
!!@categories[service.consent_category.key]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def category_allowed?(category_key)
|
|
56
|
+
key = category_key.to_s
|
|
57
|
+
return true if key == Consentful.config.required_category_key
|
|
58
|
+
|
|
59
|
+
!!@categories[key]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def expired?
|
|
63
|
+
return true if @recorded_at.nil?
|
|
64
|
+
|
|
65
|
+
Time.zone.at(@recorded_at) < EXPIRY.ago
|
|
66
|
+
rescue TypeError, ArgumentError
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def matches_version?(published_version)
|
|
71
|
+
return false if published_version.nil? || @version_id.nil?
|
|
72
|
+
|
|
73
|
+
@version_id.to_i == published_version.id
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def recorded?
|
|
77
|
+
@version_id.present? && @recorded_at.present?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
class ConsentVisitorLink < ApplicationRecord
|
|
5
|
+
self.table_name = "consent_visitor_links"
|
|
6
|
+
|
|
7
|
+
# Engine ships `email` only; hosts extend via
|
|
8
|
+
# Consentful.config.visitor_identifier_types in an initializer.
|
|
9
|
+
enum :identifier_type, Consentful.config.visitor_identifier_types
|
|
10
|
+
|
|
11
|
+
validates :visitor_uuid, presence: true
|
|
12
|
+
validates :identifier_value, presence: true,
|
|
13
|
+
uniqueness: { scope: [ :visitor_uuid, :identifier_type ] }
|
|
14
|
+
validates :linked_at, presence: true
|
|
15
|
+
|
|
16
|
+
before_validation :default_linked_at, on: :create
|
|
17
|
+
|
|
18
|
+
scope :for_value, ->(type, value) { where(identifier_type: type, identifier_value: value) }
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def default_linked_at
|
|
23
|
+
self.linked_at ||= Time.current
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
class ConsentRecordPresenter
|
|
5
|
+
ALLOWED = "allowed"
|
|
6
|
+
DENIED = "denied"
|
|
7
|
+
|
|
8
|
+
def initialize(record)
|
|
9
|
+
@record = record
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def email_text
|
|
13
|
+
[
|
|
14
|
+
header,
|
|
15
|
+
summary_block,
|
|
16
|
+
categories_block,
|
|
17
|
+
service_overrides_block,
|
|
18
|
+
context_block,
|
|
19
|
+
copy_text_block
|
|
20
|
+
].compact.join("\n\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :record
|
|
26
|
+
|
|
27
|
+
def header
|
|
28
|
+
"Consent event recorded #{record.recorded_at.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def summary_block
|
|
32
|
+
[
|
|
33
|
+
"Source: #{record.source.to_s.humanize}",
|
|
34
|
+
"Visitor reference: #{record.visitor_uuid}",
|
|
35
|
+
"Copy version: ##{record.consent_copy_version_id}"
|
|
36
|
+
].join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def categories_block
|
|
40
|
+
return if record.category_choices.blank?
|
|
41
|
+
|
|
42
|
+
lines = record.category_choices.map { |key, value| " - #{key}: #{value ? ALLOWED : DENIED}" }
|
|
43
|
+
"Categories chosen:\n#{lines.join("\n")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def service_overrides_block
|
|
47
|
+
return if record.service_overrides.blank?
|
|
48
|
+
|
|
49
|
+
lines = record.service_overrides.map { |slug, value| " - #{slug}: #{value ? ALLOWED : DENIED}" }
|
|
50
|
+
"Per-service overrides:\n#{lines.join("\n")}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def context_block
|
|
54
|
+
parts = []
|
|
55
|
+
parts << "User agent: #{record.user_agent}" if record.user_agent.present?
|
|
56
|
+
parts << "Referer: #{record.referer}" if record.referer.present?
|
|
57
|
+
return if parts.empty?
|
|
58
|
+
|
|
59
|
+
parts.join("\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def copy_text_block
|
|
63
|
+
version = record.consent_copy_version
|
|
64
|
+
return if version.nil?
|
|
65
|
+
|
|
66
|
+
lines = [ "Copy shown to the visitor:" ]
|
|
67
|
+
lines << " Title: #{version.banner_title}" if version.banner_title.present?
|
|
68
|
+
lines << " Body: #{version.banner_body}" if version.banner_body.present?
|
|
69
|
+
lines << " Modal intro: #{version.modal_intro}" if version.modal_intro.present?
|
|
70
|
+
lines.join("\n")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
class DashboardReport
|
|
5
|
+
DEFAULT_DAYS = 30
|
|
6
|
+
|
|
7
|
+
attr_reader :days, :since
|
|
8
|
+
|
|
9
|
+
def initialize(days: nil)
|
|
10
|
+
@days = (days.presence&.to_i || DEFAULT_DAYS).clamp(1, 365)
|
|
11
|
+
@since = @days.days.ago
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def total_records
|
|
15
|
+
@total_records ||= scope.count
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def category_acceptance
|
|
19
|
+
@category_acceptance ||= ConsentCategory.order(:position).map do |category|
|
|
20
|
+
accepted = scope.where("category_choices ->> ? = 'true'", category.key).count
|
|
21
|
+
{ category:, accepted:, total: total_records, rate: percentage(accepted, total_records) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def source_breakdown
|
|
26
|
+
counts = scope.group(:source).count
|
|
27
|
+
ConsentRecord.sources.keys.map do |source|
|
|
28
|
+
count = counts.fetch(source, 0)
|
|
29
|
+
{ source: source.to_sym, count:, rate: percentage(count, total_records) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def scope
|
|
36
|
+
@scope ||= ConsentRecord.within(@since..Time.current)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def percentage(numerator, denominator)
|
|
40
|
+
return 0.0 if denominator.zero?
|
|
41
|
+
|
|
42
|
+
(numerator * 100.0 / denominator).round(1)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Consentful
|
|
4
|
+
# Resolves a SAR enquiry (email or visitor UUID) into every consent_record
|
|
5
|
+
# we hold for that subject, with each event's copy version snapshot inlined
|
|
6
|
+
# so the response shows exactly what the visitor saw when they consented.
|
|
7
|
+
#
|
|
8
|
+
# Two entry points:
|
|
9
|
+
# - Email: looked up via consent_visitor_links → all matching visitor UUIDs
|
|
10
|
+
# - Visitor UUID: used directly
|
|
11
|
+
class SarLookup
|
|
12
|
+
attr_reader :email, :visitor_uuid
|
|
13
|
+
|
|
14
|
+
def initialize(email: nil, visitor_uuid: nil)
|
|
15
|
+
@email = Consentful.normalize_value(:email, email).presence
|
|
16
|
+
@visitor_uuid = visitor_uuid.to_s.strip.presence
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def given_any_input?
|
|
20
|
+
@email.present? || @visitor_uuid.present?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visitor_uuids
|
|
24
|
+
@visitor_uuids ||=
|
|
25
|
+
if @visitor_uuid
|
|
26
|
+
[ @visitor_uuid ]
|
|
27
|
+
elsif @email
|
|
28
|
+
ConsentVisitorLink.for_value(:email, @email).pluck(:visitor_uuid).uniq
|
|
29
|
+
else
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def records
|
|
35
|
+
@records ||= ConsentRecord
|
|
36
|
+
.includes(:consent_copy_version)
|
|
37
|
+
.where(visitor_uuid: visitor_uuids)
|
|
38
|
+
.order(recorded_at: :desc)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def linked_identifiers
|
|
42
|
+
@linked_identifiers ||= ConsentVisitorLink
|
|
43
|
+
.where(visitor_uuid: visitor_uuids)
|
|
44
|
+
.order(linked_at: :asc)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<h1>Edit category</h1>
|
|
2
|
+
|
|
3
|
+
<%= form_with model: @category, scope: :consent_category, url: admin_category_path(@category) do |f| %>
|
|
4
|
+
<div class="cf-card">
|
|
5
|
+
<div class="cf-field">
|
|
6
|
+
<label>Key</label>
|
|
7
|
+
<input type="text" value="<%= @category.key %>" disabled>
|
|
8
|
+
<p class="cf-hint">The key is fixed; it identifies the category in cookies and code.</p>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="cf-field"><%= f.label :name %><%= f.text_field :name %></div>
|
|
11
|
+
<div class="cf-field"><%= f.label :description %><%= f.text_area :description %></div>
|
|
12
|
+
<div class="cf-field">
|
|
13
|
+
<%= f.label :gtag_keys, "Google consent-mode signals" %>
|
|
14
|
+
<%= f.text_field :gtag_keys, value: @category.gtag_keys.join(", ") %>
|
|
15
|
+
<p class="cf-hint">Comma-separated, e.g. analytics_storage, ad_storage.</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="cf-actions">
|
|
18
|
+
<%= f.submit "Save", class: "cf-btn cf-btn--primary" %>
|
|
19
|
+
<%= link_to "Cancel", admin_categories_path, class: "cf-btn" %>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<h1>Categories</h1>
|
|
2
|
+
|
|
3
|
+
<table class="cf-table">
|
|
4
|
+
<thead><tr><th>Position</th><th>Key</th><th>Name</th><th>Required</th><th>Consent-mode signals</th><th></th></tr></thead>
|
|
5
|
+
<tbody>
|
|
6
|
+
<% @categories.each do |category| %>
|
|
7
|
+
<tr>
|
|
8
|
+
<td><%= category.position %></td>
|
|
9
|
+
<td><code><%= category.key %></code></td>
|
|
10
|
+
<td><%= category.name %></td>
|
|
11
|
+
<td><%= category.required? ? "Yes" : "No" %></td>
|
|
12
|
+
<td class="cf-muted"><%= category.gtag_keys.join(", ") %></td>
|
|
13
|
+
<td><%= link_to "Edit", edit_admin_category_path(category), class: "cf-btn" %></td>
|
|
14
|
+
</tr>
|
|
15
|
+
<% end %>
|
|
16
|
+
</tbody>
|
|
17
|
+
</table>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%= form_with model: @version, scope: :consent_copy_version,
|
|
2
|
+
url: (@version.persisted? ? admin_copy_version_path(@version) : admin_copy_versions_path) do |f| %>
|
|
3
|
+
<% if @version.errors.any? %>
|
|
4
|
+
<div class="cf-flash cf-flash--alert"><%= @version.errors.full_messages.to_sentence %></div>
|
|
5
|
+
<% end %>
|
|
6
|
+
|
|
7
|
+
<div class="cf-card">
|
|
8
|
+
<h2>Banner & modal copy</h2>
|
|
9
|
+
<div class="cf-field"><%= f.label :banner_title %><%= f.text_field :banner_title %></div>
|
|
10
|
+
<div class="cf-field"><%= f.label :banner_body %><%= f.text_area :banner_body %></div>
|
|
11
|
+
<div class="cf-field"><%= f.label :modal_intro %><%= f.text_area :modal_intro %></div>
|
|
12
|
+
<div class="cf-field"><%= f.label :accept_label %><%= f.text_field :accept_label %></div>
|
|
13
|
+
<div class="cf-field"><%= f.label :reject_label %><%= f.text_field :reject_label %></div>
|
|
14
|
+
<div class="cf-field"><%= f.label :manage_label %><%= f.text_field :manage_label %></div>
|
|
15
|
+
<div class="cf-field"><%= f.label :save_label %><%= f.text_field :save_label %></div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<% if (@version.category_blurbs || {}).any? %>
|
|
19
|
+
<div class="cf-card">
|
|
20
|
+
<h2>Category blurbs</h2>
|
|
21
|
+
<% @version.category_blurbs.each do |key, blurb| %>
|
|
22
|
+
<div class="cf-field">
|
|
23
|
+
<label><%= key.to_s.humanize %></label>
|
|
24
|
+
<%= text_area_tag "consent_copy_version[category_blurbs][#{key}]", (blurb.is_a?(Hash) ? blurb["description"] : blurb) %>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<div class="cf-actions">
|
|
31
|
+
<%= f.submit (@version.persisted? ? "Save draft" : "Create draft"), class: "cf-btn cf-btn--primary" %>
|
|
32
|
+
<%= link_to "Cancel", admin_copy_versions_path, class: "cf-btn" %>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<h1>Consent copy versions</h1>
|
|
2
|
+
|
|
3
|
+
<div class="cf-actions" style="margin-bottom:16px;">
|
|
4
|
+
<%= link_to "New draft", new_admin_copy_version_path, class: "cf-btn cf-btn--primary" %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<table class="cf-table">
|
|
8
|
+
<thead><tr><th>ID</th><th>Status</th><th>Banner title</th><th>Published</th><th>By</th><th>Actions</th></tr></thead>
|
|
9
|
+
<tbody>
|
|
10
|
+
<% @versions.each do |version| %>
|
|
11
|
+
<tr>
|
|
12
|
+
<td>#<%= version.id %></td>
|
|
13
|
+
<td><span class="cf-badge cf-badge--<%= version.status %>"><%= version.status %></span></td>
|
|
14
|
+
<td><%= version.banner_title %></td>
|
|
15
|
+
<td class="cf-muted"><%= version.published_at&.strftime("%Y-%m-%d %H:%M") %></td>
|
|
16
|
+
<td class="cf-muted"><%= version.published_by %></td>
|
|
17
|
+
<td>
|
|
18
|
+
<div class="cf-actions">
|
|
19
|
+
<%= link_to "View", admin_copy_version_path(version), class: "cf-btn" %>
|
|
20
|
+
<% if version.draft? %>
|
|
21
|
+
<%= link_to "Edit", edit_admin_copy_version_path(version), class: "cf-btn" %>
|
|
22
|
+
<%= button_to "Publish", publish_admin_copy_version_path(version), class: "cf-btn cf-btn--gold" %>
|
|
23
|
+
<%= button_to "Delete", admin_copy_version_path(version), method: :delete, class: "cf-btn cf-btn--danger", form: { data: { turbo_confirm: "Delete this draft?" } } %>
|
|
24
|
+
<% elsif version.published? %>
|
|
25
|
+
<%= button_to "Archive", archive_admin_copy_version_path(version), class: "cf-btn" %>
|
|
26
|
+
<% else %>
|
|
27
|
+
<%= button_to "Clone to draft", restore_admin_copy_version_path(version), class: "cf-btn" %>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
30
|
+
</td>
|
|
31
|
+
</tr>
|
|
32
|
+
<% end %>
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<h1>Copy version #<%= @version.id %> <span class="cf-badge cf-badge--<%= @version.status %>"><%= @version.status %></span></h1>
|
|
2
|
+
|
|
3
|
+
<div class="cf-card">
|
|
4
|
+
<p><strong><%= @version.banner_title %></strong></p>
|
|
5
|
+
<p><%= @version.banner_body %></p>
|
|
6
|
+
<p class="cf-muted"><%= @version.modal_intro %></p>
|
|
7
|
+
<p class="cf-muted">Buttons: <%= [@version.accept_label, @version.reject_label, @version.manage_label, @version.save_label].compact_blank.join(" · ") %></p>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="cf-card">
|
|
11
|
+
<p class="cf-muted">Content digest</p>
|
|
12
|
+
<code><%= @version.digest %></code>
|
|
13
|
+
<button type="button" class="cf-btn"
|
|
14
|
+
data-controller="consentful--clipboard"
|
|
15
|
+
data-consentful--clipboard-text-value="<%= @version.digest %>"
|
|
16
|
+
data-action="consentful--clipboard#copy">Copy digest</button>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="cf-actions">
|
|
20
|
+
<%= link_to "Back", admin_copy_versions_path, class: "cf-btn" %>
|
|
21
|
+
<% if @version.draft? %>
|
|
22
|
+
<%= link_to "Edit", edit_admin_copy_version_path(@version), class: "cf-btn cf-btn--primary" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|