Add effective date to terms of service

This commit is contained in:
Eugen Rochko 2025-02-24 21:42:18 +01:00
parent b1692628de
commit 7786890326
23 changed files with 180 additions and 43 deletions

View File

@ -23,7 +23,7 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
private
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
def current_terms_of_service
@ -32,6 +32,6 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
def resource_params
params
.expect(terms_of_service: [:text, :changelog])
.expect(terms_of_service: [:text, :changelog, :effective_date])
end
end

View File

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

View File

@ -5,12 +5,18 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
def show
cache_even_if_authenticated!
render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer
render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer
end
private
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

View File

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

View File

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

View File

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

View File

@ -205,7 +205,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} 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} />
<Redirect from='/timelines/public' to='/public' exact />

View File

@ -872,7 +872,9 @@
"subscribed_languages.target": "Change subscribed languages for {target}",
"tabs_bar.home": "Home",
"tabs_bar.notifications": "Notifications",
"terms_of_service.effective_as_of": "Effective as of {date}",
"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.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",

View File

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

View File

@ -6,6 +6,7 @@
#
# id :bigint(8) not null, primary key
# changelog :text default(""), not null
# effective_date :date
# notification_sent_at :datetime
# published_at :datetime
# text :text default(""), not null
@ -13,17 +14,27 @@
# updated_at :datetime not null
#
class TermsOfService < ApplicationRecord
scope :published, -> { where.not(published_at: nil).order(published_at: :desc) }
scope :live, -> { published.limit(1) }
scope :published, -> { where.not(published_at: nil).order(effective_date: :desc) }
scope :live, -> { published.where('effective_date < now()').limit(1) }
scope :draft, -> { where(published_at: nil).order(id: :desc).limit(1) }
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?
published_at.present?
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?
notification_sent_at.present?
end
@ -31,4 +42,14 @@ class TermsOfService < ApplicationRecord
def scope_for_notification
User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at))
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

View File

@ -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

View File

@ -14,6 +14,9 @@
.fields-group
= form.input :changelog, wrapper: :with_block_label, input_html: { rows: 8 }
.fields-group
= form.input :effective_date, wrapper: :with_block_label, as: :date
.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.publish'), type: :submit, name: :action_type, value: :publish

View File

@ -10,7 +10,11 @@
.admin__terms-of-service__container__header
.dot-indicator.success
.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
= 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))

View File

@ -9,7 +9,7 @@
%table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%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
%strong= t('user_mailer.terms_of_service_changed.changelog')
= markdown(@terms_of_service.changelog)

View File

@ -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') %>

View File

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

View File

@ -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."
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
going_live_on_html: Live, effective %{date}
history: History
live: Live
no_history: There are no recorded changes of the terms of service yet.
@ -1937,8 +1938,8 @@ en:
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.
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_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: '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}. 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
subject: Updates to our terms of service
subtitle: The terms of service of %{domain} are changing

View File

@ -132,6 +132,7 @@ en:
name: You can only change the casing of the letters, for example, to make it more readable
terms_of_service:
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.
terms_of_service_generator:
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
terms_of_service:
changelog: What's changed?
effective_date: Effective date
text: Terms of Service
terms_of_service_generator:
admin_email: Email address for legal notices

View File

@ -204,7 +204,8 @@ Rails.application.routes.draw do
get '/privacy-policy', to: 'privacy#show', as: :privacy_policy
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 '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false

View File

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

View File

@ -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

View File

@ -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_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
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 "created_at", null: false
t.datetime "updated_at", null: false
t.date "effective_date"
end
create_table "tombstones", force: :cascade do |t|

View File

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