Add effective date to terms of service

feature-terms-effective-date
Eugen Rochko 4 days ago
parent b1692628de
commit 7786890326

@ -23,7 +23,7 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
private private
def set_terms_of_service def set_terms_of_service
@terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text) @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text, effective_date: 10.days.from_now)
end end
def current_terms_of_service def current_terms_of_service
@ -32,6 +32,6 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
def resource_params def resource_params
params params
.expect(terms_of_service: [:text, :changelog]) .expect(terms_of_service: [:text, :changelog, :effective_date])
end end
end end

@ -3,6 +3,6 @@
class Admin::TermsOfServiceController < Admin::BaseController class Admin::TermsOfServiceController < Admin::BaseController
def index def index
authorize :terms_of_service, :index? authorize :terms_of_service, :index?
@terms_of_service = TermsOfService.live.first @terms_of_service = TermsOfService.published.first
end end
end end

@ -5,12 +5,18 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
def show def show
cache_even_if_authenticated! cache_even_if_authenticated!
render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer
end end
private private
def set_terms_of_service def set_terms_of_service
@terms_of_service = TermsOfService.live.first! @terms_of_service = begin
if params[:date].present?
TermsOfService.published.find_by!(effective_date: params[:date])
else
TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet
end
end
end end
end end

@ -4,8 +4,12 @@ import type {
ApiPrivacyPolicyJSON, ApiPrivacyPolicyJSON,
} from 'mastodon/api_types/instance'; } from 'mastodon/api_types/instance';
export const apiGetTermsOfService = () => export const apiGetTermsOfService = (version?: string) =>
apiRequestGet<ApiTermsOfServiceJSON>('v1/instance/terms_of_service'); apiRequestGet<ApiTermsOfServiceJSON>(
version
? `v1/instance/terms_of_service/${version}`
: 'v1/instance/terms_of_service',
);
export const apiGetPrivacyPolicy = () => export const apiGetPrivacyPolicy = () =>
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy'); apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');

@ -1,5 +1,7 @@
export interface ApiTermsOfServiceJSON { export interface ApiTermsOfServiceJSON {
updated_at: string; effective_date: string;
effective: boolean;
succeeded_by: string | null;
content: string; content: string;
} }

@ -8,26 +8,31 @@ import {
} from 'react-intl'; } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Link, useParams } from 'react-router-dom';
import { apiGetTermsOfService } from 'mastodon/api/instance'; import { apiGetTermsOfService } from 'mastodon/api/instance';
import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance'; import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { Skeleton } from 'mastodon/components/skeleton';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' }, title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' },
}); });
interface Params {
date?: string;
}
const TermsOfService: React.FC<{ const TermsOfService: React.FC<{
multiColumn: boolean; multiColumn: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const { date } = useParams<Params>();
const [response, setResponse] = useState<ApiTermsOfServiceJSON>(); const [response, setResponse] = useState<ApiTermsOfServiceJSON>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
apiGetTermsOfService() apiGetTermsOfService(date)
.then((data) => { .then((data) => {
setResponse(data); setResponse(data);
setLoading(false); setLoading(false);
@ -36,7 +41,7 @@ const TermsOfService: React.FC<{
.catch(() => { .catch(() => {
setLoading(false); setLoading(false);
}); });
}, []); }, [date]);
if (!loading && !response) { if (!loading && !response) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />; return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
@ -55,23 +60,60 @@ const TermsOfService: React.FC<{
defaultMessage='Terms of Service' defaultMessage='Terms of Service'
/> />
</h3> </h3>
<p> <p className='prose'>
<FormattedMessage {response?.effective ? (
id='privacy_policy.last_updated' <FormattedMessage
defaultMessage='Last updated {date}' id='privacy_policy.last_updated'
values={{ defaultMessage='Last updated {date}'
date: loading ? ( values={{
<Skeleton width='10ch' /> date: (
) : ( <FormattedDate
<FormattedDate value={response.effective_date}
value={response?.updated_at} year='numeric'
year='numeric' month='short'
month='short' day='2-digit'
day='2-digit' />
),
}}
/>
) : (
<FormattedMessage
id='terms_of_service.effective_as_of'
defaultMessage='Effective as of {date}'
values={{
date: (
<FormattedDate
value={response?.effective_date}
year='numeric'
month='short'
day='2-digit'
/>
),
}}
/>
)}
{response?.succeeded_by && (
<>
{' · '}
<Link to={`/terms-of-service/${response.succeeded_by}`}>
<FormattedMessage
id='terms_of_service.upcoming_changes_on'
defaultMessage='Upcoming changes on {date}'
values={{
date: (
<FormattedDate
value={response.succeeded_by}
year='numeric'
month='short'
day='2-digit'
/>
),
}}
/> />
), </Link>
}} </>
/> )}
</p> </p>
</div> </div>

@ -205,7 +205,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} content={children} /> <WrappedRoute path='/about' component={About} content={children} />
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} /> <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path='/terms-of-service' component={TermsOfService} content={children} /> <WrappedRoute path='/terms-of-service/:date?' component={TermsOfService} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} /> <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<Redirect from='/timelines/public' to='/public' exact /> <Redirect from='/timelines/public' to='/public' exact />

@ -872,7 +872,9 @@
"subscribed_languages.target": "Change subscribed languages for {target}", "subscribed_languages.target": "Change subscribed languages for {target}",
"tabs_bar.home": "Home", "tabs_bar.home": "Home",
"tabs_bar.notifications": "Notifications", "tabs_bar.notifications": "Notifications",
"terms_of_service.effective_as_of": "Effective as of {date}",
"terms_of_service.title": "Terms of Service", "terms_of_service.title": "Terms of Service",
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left", "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",

@ -340,10 +340,17 @@ code {
columns: unset; columns: unset;
} }
.input.datetime .label_input select { .input.datetime .label_input,
display: inline-block; .input.date .label_input {
width: auto; display: flex;
flex: 0; gap: 4px;
align-items: center;
select {
display: inline-block;
width: auto;
flex: 0;
}
} }
.input.select.select--languages { .input.select.select--languages {

@ -6,6 +6,7 @@
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# changelog :text default(""), not null # changelog :text default(""), not null
# effective_date :date
# notification_sent_at :datetime # notification_sent_at :datetime
# published_at :datetime # published_at :datetime
# text :text default(""), not null # text :text default(""), not null
@ -13,17 +14,27 @@
# updated_at :datetime not null # updated_at :datetime not null
# #
class TermsOfService < ApplicationRecord class TermsOfService < ApplicationRecord
scope :published, -> { where.not(published_at: nil).order(published_at: :desc) } scope :published, -> { where.not(published_at: nil).order(effective_date: :desc) }
scope :live, -> { published.limit(1) } scope :live, -> { published.where('effective_date < now()').limit(1) }
scope :draft, -> { where(published_at: nil).order(id: :desc).limit(1) } scope :draft, -> { where(published_at: nil).order(id: :desc).limit(1) }
validates :text, presence: true validates :text, presence: true
validates :changelog, presence: true, if: -> { published? } validates :changelog, :effective_date, presence: true, if: -> { published? }
validate :effective_date_cannot_be_in_the_past
def published? def published?
published_at.present? published_at.present?
end end
def effective?
published? && effective_date.past?
end
def succeeded_by
TermsOfService.published.where(effective_date: (effective_date..)).where.not(id: id).first
end
def notification_sent? def notification_sent?
notification_sent_at.present? notification_sent_at.present?
end end
@ -31,4 +42,14 @@ class TermsOfService < ApplicationRecord
def scope_for_notification def scope_for_notification
User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at)) User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at))
end end
private
def effective_date_cannot_be_in_the_past
return if effective_date.blank?
min_date = TermsOfService.live.pick(:effective_date) || Time.zone.today
errors.add(:effective_date, :too_soon, date: min_date) if effective_date < min_date
end
end end

@ -0,0 +1,27 @@
# frozen_string_literal: true
class REST::TermsOfServiceSerializer < ActiveModel::Serializer
attributes :effective_date, :effective, :content, :succeeded_by
def effective_date
object.effective_date.iso8601
end
def effective
object.effective?
end
def succeeded_by
object.succeeded_by&.effective_date&.iso8601
end
def content
markdown.render(format(object.text, domain: Rails.configuration.x.local_domain))
end
private
def markdown
@markdown ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true)
end
end

@ -14,6 +14,9 @@
.fields-group .fields-group
= form.input :changelog, wrapper: :with_block_label, input_html: { rows: 8 } = form.input :changelog, wrapper: :with_block_label, input_html: { rows: 8 }
.fields-group
= form.input :effective_date, wrapper: :with_block_label, as: :date
.actions .actions
= form.button :button, t('admin.terms_of_service.save_draft'), type: :submit, name: :action_type, value: :save_draft, class: 'button button-secondary' = form.button :button, t('admin.terms_of_service.save_draft'), type: :submit, name: :action_type, value: :save_draft, class: 'button button-secondary'
= form.button :button, t('admin.terms_of_service.publish'), type: :submit, name: :action_type, value: :publish = form.button :button, t('admin.terms_of_service.publish'), type: :submit, name: :action_type, value: :publish

@ -10,7 +10,11 @@
.admin__terms-of-service__container__header .admin__terms-of-service__container__header
.dot-indicator.success .dot-indicator.success
.dot-indicator__indicator .dot-indicator__indicator
%span= t('admin.terms_of_service.live') %span
- if @terms_of_service.effective?
= t('admin.terms_of_service.live')
- else
= t('admin.terms_of_service.going_live_on_html', date: tag.time(l(@terms_of_service.effective_date), class: 'formatted', date: @terms_of_service.effective_date.iso8601))
· ·
%span %span
= t('admin.terms_of_service.published_on_html', date: tag.time(l(@terms_of_service.published_at.to_date), class: 'formatted', date: @terms_of_service.published_at.to_date.iso8601)) = t('admin.terms_of_service.published_on_html', date: tag.time(l(@terms_of_service.published_at.to_date), class: 'formatted', date: @terms_of_service.published_at.to_date.iso8601))

@ -9,7 +9,7 @@
%table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } %table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr %tr
%td.email-inner-card-td.email-prose %td.email-inner-card-td.email-prose
%p= t('user_mailer.terms_of_service_changed.description_html', path: terms_of_service_url, domain: site_hostname) %p= t('user_mailer.terms_of_service_changed.description_html', path: terms_of_service_version_url(date: @terms_of_service.effective_date), domain: site_hostname, date: l(@terms_of_service.effective_date))
%p %p
%strong= t('user_mailer.terms_of_service_changed.changelog') %strong= t('user_mailer.terms_of_service_changed.changelog')
= markdown(@terms_of_service.changelog) = markdown(@terms_of_service.changelog)

@ -2,9 +2,9 @@
=== ===
<%= t('user_mailer.terms_of_service_changed.description', domain: site_hostname) %> <%= t('user_mailer.terms_of_service_changed.description', domain: site_hostname, date: @terms_of_service.effective_date) %>
=> <%= terms_of_service_url %> => <%= terms_of_service_version_url(date: @terms_of_service.effective_date) %>
<%= t('user_mailer.terms_of_service_changed.changelog') %> <%= t('user_mailer.terms_of_service_changed.changelog') %>

@ -49,6 +49,10 @@ en:
attributes: attributes:
reblog: reblog:
taken: of post already exists taken: of post already exists
terms_of_service:
attributes:
effective_date:
too_soon: is too soon, must be later than %{date}
user: user:
attributes: attributes:
email: email:

@ -939,6 +939,7 @@ en:
chance_to_review_html: "<strong>The generated terms of service will not be published automatically.</strong> You will have a chance to review the results. Please fill in the necessary details to proceed." chance_to_review_html: "<strong>The generated terms of service will not be published automatically.</strong> You will have a chance to review the results. Please fill in the necessary details to proceed."
explanation_html: The terms of service template provided is for informational purposes only, and should not be construed as legal advice on any subject matter. Please consult with your own legal counsel on your situation and specific legal questions you have. explanation_html: The terms of service template provided is for informational purposes only, and should not be construed as legal advice on any subject matter. Please consult with your own legal counsel on your situation and specific legal questions you have.
title: Terms of Service Setup title: Terms of Service Setup
going_live_on_html: Live, effective %{date}
history: History history: History
live: Live live: Live
no_history: There are no recorded changes of the terms of service yet. no_history: There are no recorded changes of the terms of service yet.
@ -1937,8 +1938,8 @@ en:
terms_of_service_changed: terms_of_service_changed:
agreement: By continuing to use %{domain}, you are agreeing to these terms. If you disagree with the updated terms, you may terminate your agreement with %{domain} at any time by deleting your account. agreement: By continuing to use %{domain}, you are agreeing to these terms. If you disagree with the updated terms, you may terminate your agreement with %{domain} at any time by deleting your account.
changelog: 'At a glance, here is what this update means for you:' changelog: 'At a glance, here is what this update means for you:'
description: 'You are receiving this e-mail because we''re making some changes to our terms of service at %{domain}. We encourage you to review the updated terms in full here:' description: 'You are receiving this e-mail because we''re making some changes to our terms of service at %{domain}. These updates will take effect %{date}. We encourage you to review the updated terms in full here:'
description_html: You are receiving this e-mail because we're making some changes to our terms of service at %{domain}. We encourage you to review the <a href="%{path}" target="_blank">updated terms in full here</a>. description_html: You are receiving this e-mail because we're making some changes to our terms of service at %{domain}. These updates will take effect <strong>%{date}</strong>. We encourage you to review the <a href="%{path}" target="_blank">updated terms in full here</a>.
sign_off: The %{domain} team sign_off: The %{domain} team
subject: Updates to our terms of service subject: Updates to our terms of service
subtitle: The terms of service of %{domain} are changing subtitle: The terms of service of %{domain} are changing

@ -132,6 +132,7 @@ en:
name: You can only change the casing of the letters, for example, to make it more readable name: You can only change the casing of the letters, for example, to make it more readable
terms_of_service: terms_of_service:
changelog: Can be structured with Markdown syntax. changelog: Can be structured with Markdown syntax.
effective_date: A reasonable timeframe can range anywhere from 10 to 30 days from the date you notify your users.
text: Can be structured with Markdown syntax. text: Can be structured with Markdown syntax.
terms_of_service_generator: terms_of_service_generator:
admin_email: Legal notices include counternotices, court orders, takedown requests, and law enforcement requests. admin_email: Legal notices include counternotices, court orders, takedown requests, and law enforcement requests.
@ -333,6 +334,7 @@ en:
usable: Allow posts to use this hashtag locally usable: Allow posts to use this hashtag locally
terms_of_service: terms_of_service:
changelog: What's changed? changelog: What's changed?
effective_date: Effective date
text: Terms of Service text: Terms of Service
terms_of_service_generator: terms_of_service_generator:
admin_email: Email address for legal notices admin_email: Email address for legal notices

@ -204,7 +204,8 @@ Rails.application.routes.draw do
get '/privacy-policy', to: 'privacy#show', as: :privacy_policy get '/privacy-policy', to: 'privacy#show', as: :privacy_policy
get '/terms-of-service', to: 'terms_of_service#show', as: :terms_of_service get '/terms-of-service', to: 'terms_of_service#show', as: :terms_of_service
get '/terms', to: redirect('/terms-of-service') get '/terms-of-service/:date', to: 'terms_of_service#show', as: :terms_of_service_version
get '/terms', to: redirect('/terms-of-service')
match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false
match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false

@ -121,6 +121,8 @@ namespace :api, format: false do
resource :translation_languages, only: [:show] resource :translation_languages, only: [:show]
resource :languages, only: [:show] resource :languages, only: [:show]
resource :activity, only: [:show], controller: :activity resource :activity, only: [:show], controller: :activity
get '/terms_of_service/:date', to: 'terms_of_services#show'
end end
end end

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddEffectiveDateToTermsOfServices < ActiveRecord::Migration[8.0]
def change
add_column :terms_of_services, :effective_date, :date
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_29_144813) do ActiveRecord::Schema[8.0].define(version: 2025_02_24_144617) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -1110,6 +1110,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_29_144813) do
t.datetime "notification_sent_at" t.datetime "notification_sent_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.date "effective_date"
end end
create_table "tombstones", force: :cascade do |t| create_table "tombstones", force: :cascade do |t|

@ -5,4 +5,5 @@ Fabricator(:terms_of_service) do
changelog { Faker::Lorem.paragraph } changelog { Faker::Lorem.paragraph }
published_at { Time.zone.now } published_at { Time.zone.now }
notification_sent_at { Time.zone.now } notification_sent_at { Time.zone.now }
effective_date { Time.zone.tomorrow }
end end

Loading…
Cancel
Save