Add slugs for Posts, Boards and OAuths (#321)

This commit is contained in:
Riccardo Graziosi
2024-04-05 18:23:31 +02:00
committed by GitHub
parent e887bca9cf
commit 09fb156a4e
30 changed files with 262 additions and 25 deletions

View File

@@ -44,6 +44,9 @@ gem 'kaminari', '1.2.2'
# DDoS protection # DDoS protection
gem 'rack-attack', '6.7.0' gem 'rack-attack', '6.7.0'
# Slugs
gem 'friendly_id', '5.5.1'
group :development, :test do group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]

View File

@@ -103,6 +103,8 @@ GEM
factory_bot (~> 5.0.2) factory_bot (~> 5.0.2)
railties (>= 4.2.0) railties (>= 4.2.0)
ffi (1.15.5) ffi (1.15.5)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
httparty (0.21.0) httparty (0.21.0)
@@ -286,6 +288,7 @@ DEPENDENCIES
cssbundling-rails (= 1.1.2) cssbundling-rails (= 1.1.2)
devise (= 4.7.3) devise (= 4.7.3)
factory_bot_rails (= 5.0.2) factory_bot_rails (= 5.0.2)
friendly_id (= 5.5.1)
httparty (= 0.21.0) httparty (= 0.21.0)
i18n-js (= 3.9.2) i18n-js (= 3.9.2)
jbuilder (= 2.11.5) jbuilder (= 2.11.5)

View File

@@ -35,7 +35,7 @@ class ApplicationController < ActionController::Base
# Load tenant data # Load tenant data
@tenant = Current.tenant_or_raise! @tenant = Current.tenant_or_raise!
@tenant_setting = TenantSetting.first_or_create @tenant_setting = TenantSetting.first_or_create
@boards = Board.select(:id, :name).order(order: :asc) @boards = Board.select(:id, :name, :slug).order(order: :asc)
# Setup locale # Setup locale
I18n.locale = @tenant.locale I18n.locale = @tenant.locale

View File

@@ -10,7 +10,7 @@ class BoardsController < ApplicationController
end end
def show def show
@board = Board.find(params[:id]) @board = Board.friendly.find(params[:id])
@page_title = @board.name @page_title = @board.name
end end
@@ -80,6 +80,6 @@ class BoardsController < ApplicationController
def board_params def board_params
params params
.require(:board) .require(:board)
.permit(:name, :description) .permit(:name, :description, :slug)
end end
end end

View File

@@ -12,9 +12,9 @@ class OAuthsController < ApplicationController
# Generates authorize url with required parameters and redirects to provider # Generates authorize url with required parameters and redirects to provider
def start def start
if params[:reason] == 'tenantsignup' if params[:reason] == 'tenantsignup'
@o_auth = OAuth.include_only_defaults.find(params[:id]) @o_auth = OAuth.include_only_defaults.friendly.find(params[:id])
else else
@o_auth = OAuth.include_defaults.find(params[:id]) @o_auth = OAuth.include_defaults.friendly.find(params[:id])
end end
return if params[:reason] != 'test' and not @o_auth.is_enabled? return if params[:reason] != 'test' and not @o_auth.is_enabled?
@@ -42,9 +42,9 @@ class OAuthsController < ApplicationController
Current.tenant ||= Tenant.find_by(subdomain: tenant_domain) Current.tenant ||= Tenant.find_by(subdomain: tenant_domain)
if reason == 'tenantsignup' if reason == 'tenantsignup'
@o_auth = OAuth.include_only_defaults.find(params[:id]) @o_auth = OAuth.include_only_defaults.friendly.find(params[:id])
else else
@o_auth = OAuth.include_defaults.find(params[:id]) @o_auth = OAuth.include_defaults.friendly.find(params[:id])
end end
return if reason != 'test' and not @o_auth.is_enabled? return if reason != 'test' and not @o_auth.is_enabled?

View File

@@ -9,6 +9,7 @@ class PostsController < ApplicationController
.select( .select(
:id, :id,
:title, :title,
:slug,
:description, :description,
:post_status_id, :post_status_id,
'COUNT(DISTINCT likes.id) AS likes_count', 'COUNT(DISTINCT likes.id) AS likes_count',
@@ -48,9 +49,11 @@ class PostsController < ApplicationController
def show def show
@post = Post @post = Post
.friendly
.select( .select(
:id, :id,
:title, :title,
:slug,
:description, :description,
:board_id, :board_id,
:user_id, :user_id,

View File

@@ -62,8 +62,12 @@ export const deleteBoard = (
} else { } else {
dispatch(boardDeleteFailure(json.error)); dispatch(boardDeleteFailure(json.error));
} }
return Promise.resolve(res);
} catch (e) { } catch (e) {
dispatch(boardDeleteFailure(e)); dispatch(boardDeleteFailure(e));
return Promise.resolve(null);
} }
} }
); );

View File

@@ -48,6 +48,7 @@ export const updateBoard = (
id: number, id: number,
name: string, name: string,
description: string, description: string,
slug: string,
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(boardUpdateStart()); dispatch(boardUpdateStart());
@@ -60,6 +61,7 @@ export const updateBoard = (
board: { board: {
name, name,
description, description,
slug,
}, },
}), }),
}); });

View File

@@ -107,7 +107,7 @@ class NewPost extends React.Component<Props, State> {
}); });
const json = await res.json(); const json = await res.json();
this.setState({isLoading: false}); this.setState({isLoading: false});
if (res.status === HttpStatus.Created) { if (res.status === HttpStatus.Created) {
this.setState({ this.setState({
success: I18n.t('board.new_post.submit_success'), success: I18n.t('board.new_post.submit_success'),
@@ -117,7 +117,7 @@ class NewPost extends React.Component<Props, State> {
}); });
setTimeout(() => ( setTimeout(() => (
window.location.href = `/posts/${json.id}` window.location.href = `/posts/${json.slug || json.id}`
), 1000); ), 1000);
} else { } else {
this.setState({error: json.error}); this.setState({error: json.error});

View File

@@ -55,6 +55,7 @@ const PostList = ({
<PostListItem <PostListItem
id={post.id} id={post.id}
title={post.title} title={post.title}
slug={post.slug}
description={post.description} description={post.description}
postStatus={postStatuses.find(postStatus => postStatus.id === post.postStatusId)} postStatus={postStatuses.find(postStatus => postStatus.id === post.postStatusId)}
likeCount={post.likeCount} likeCount={post.likeCount}

View File

@@ -10,6 +10,7 @@ import IPostStatus from '../../interfaces/IPostStatus';
interface Props { interface Props {
id: number; id: number;
title: string; title: string;
slug?: string;
description?: string; description?: string;
postStatus: IPostStatus; postStatus: IPostStatus;
likeCount: number; likeCount: number;
@@ -25,6 +26,7 @@ interface Props {
const PostListItem = ({ const PostListItem = ({
id, id,
title, title,
slug,
description, description,
postStatus, postStatus,
likeCount, likeCount,
@@ -36,7 +38,7 @@ const PostListItem = ({
isLoggedIn, isLoggedIn,
authenticityToken, authenticityToken,
}: Props) => ( }: Props) => (
<div onClick={() => window.location.href = `/posts/${id}`} className="postListItem"> <div onClick={() => window.location.href = `/posts/${slug || id}`} className="postListItem">
<LikeButton <LikeButton
postId={id} postId={id}
likeCount={likeCount} likeCount={likeCount}
@@ -46,7 +48,7 @@ const PostListItem = ({
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
<div className="postContainer"> <div className="postContainer">
<span className="postTitle">{title}</span> <span className="postTitle">{title}</span>
<ReactMarkdown className="descriptionText" allowedTypes={['text']} unwrapDisallowed> <ReactMarkdown className="descriptionText" allowedTypes={['text']} unwrapDisallowed>

View File

@@ -14,13 +14,15 @@ interface Props {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
slug?: string;
index: number; index: number;
settingsAreUpdating: boolean; settingsAreUpdating: boolean;
handleUpdate( handleUpdate(
id: number, id: number,
description: string,
name: string, name: string,
description: string,
slug: string,
onSuccess: Function, onSuccess: Function,
): void; ): void;
handleDelete(id: number): void; handleDelete(id: number): void;
@@ -46,11 +48,12 @@ class BoardsEditable extends React.Component<Props, State> {
this.setState({editMode: !this.state.editMode}); this.setState({editMode: !this.state.editMode});
} }
handleUpdate(id: number, name: string, description: string) { handleUpdate(id: number, name: string, description: string, slug: string) {
this.props.handleUpdate( this.props.handleUpdate(
id, id,
name, name,
description, description,
slug,
() => this.setState({editMode: false}), () => this.setState({editMode: false}),
); );
} }
@@ -60,6 +63,7 @@ class BoardsEditable extends React.Component<Props, State> {
id, id,
name, name,
description, description,
slug,
index, index,
settingsAreUpdating, settingsAreUpdating,
handleDelete, handleDelete,
@@ -108,6 +112,7 @@ class BoardsEditable extends React.Component<Props, State> {
id={id} id={id}
name={name} name={name}
description={description} description={description}
slug={slug}
handleUpdate={this.handleUpdate} handleUpdate={this.handleUpdate}
/> />

View File

@@ -3,6 +3,7 @@ import { useForm, SubmitHandler } from 'react-hook-form';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Button from '../../common/Button'; import Button from '../../common/Button';
import { DangerText } from '../../common/CustomTexts';
interface Props { interface Props {
mode: 'create' | 'update'; mode: 'create' | 'update';
@@ -10,6 +11,7 @@ interface Props {
id?: number; id?: number;
name?: string; name?: string;
description?: string; description?: string;
slug?: string;
handleCreate?( handleCreate?(
name: string, name: string,
@@ -20,12 +22,14 @@ interface Props {
id: number, id: number,
name: string, name: string,
description?: string, description?: string,
slug?: string,
): void; ): void;
} }
interface IBoardForm { interface IBoardForm {
name: string; name: string;
description: string; description: string;
slug?: string;
} }
const BoardForm = ({ const BoardForm = ({
@@ -33,6 +37,7 @@ const BoardForm = ({
id, id,
name, name,
description, description,
slug,
handleCreate, handleCreate,
handleUpdate, handleUpdate,
}: Props) => { }: Props) => {
@@ -40,15 +45,19 @@ const BoardForm = ({
register, register,
handleSubmit, handleSubmit,
reset, reset,
formState: { isValid }, formState: { isValid, errors },
watch,
} = useForm<IBoardForm>({ } = useForm<IBoardForm>({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
name: name || '', name: name || '',
description: description || '', description: description || '',
slug: slug || undefined,
}, },
}); });
const formBoardName = watch('name');
const onSubmit: SubmitHandler<IBoardForm> = data => { const onSubmit: SubmitHandler<IBoardForm> = data => {
if (mode === 'create') { if (mode === 'create') {
handleCreate( handleCreate(
@@ -57,7 +66,7 @@ const BoardForm = ({
() => reset({ name: '', description: '' }) () => reset({ name: '', description: '' })
); );
} else { } else {
handleUpdate(id, data.name, data.description); handleUpdate(id, data.name, data.description, data.slug);
} }
} }
@@ -91,6 +100,25 @@ const BoardForm = ({
placeholder={I18n.t('site_settings.boards.form.description')} placeholder={I18n.t('site_settings.boards.form.description')}
className="formControl" className="formControl"
/> />
{mode === 'update' && (
<>
<div className="input-group">
<div className="input-group-prepend">
<div className="input-group-text">boards/</div>
</div>
<input
{...register('slug', { pattern: /^[a-zA-Z0-9-]+$/ })}
type="text"
placeholder={formBoardName.trim().replace(/\s/g, '-').toLowerCase()}
className="formControl"
/>
</div>
<DangerText>
{errors.slug?.type === 'pattern' && I18n.t('common.validations.url')}
</DangerText>
</>
)}
</form> </form>
); );
} }

View File

@@ -30,6 +30,7 @@ interface Props {
id: number, id: number,
name: string, name: string,
description: string, description: string,
slug: string,
onSuccess: Function, onSuccess: Function,
authenticityToken: string, authenticityToken: string,
): void; ): void;
@@ -61,8 +62,8 @@ class BoardsSiteSettingsP extends React.Component<Props> {
this.props.submitBoard(name, description, onSuccess, this.props.authenticityToken); this.props.submitBoard(name, description, onSuccess, this.props.authenticityToken);
} }
handleUpdate(id: number, name: string, description: string, onSuccess: Function) { handleUpdate(id: number, name: string, description: string, slug: string, onSuccess: Function) {
this.props.updateBoard(id, name, description, onSuccess, this.props.authenticityToken); this.props.updateBoard(id, name, description, slug, onSuccess, this.props.authenticityToken);
} }
handleDragEnd(result) { handleDragEnd(result) {
@@ -105,6 +106,7 @@ class BoardsSiteSettingsP extends React.Component<Props> {
id={board.id} id={board.id}
name={board.name} name={board.name}
description={board.description} description={board.description}
slug={board.slug}
index={i} index={i}
settingsAreUpdating={settingsAreUpdating} settingsAreUpdating={settingsAreUpdating}

View File

@@ -28,7 +28,10 @@ const mapDispatchToProps = (dispatch: any) => ({
authenticityToken: string, authenticityToken: string,
) { ) {
dispatch(submitBoard(name, description, authenticityToken)).then(res => { dispatch(submitBoard(name, description, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.Created) onSuccess(); if (res && res.status === HttpStatus.Created) {
onSuccess();
window.location.reload();
}
}); });
}, },
@@ -36,11 +39,15 @@ const mapDispatchToProps = (dispatch: any) => ({
id: number, id: number,
name: string, name: string,
description: string, description: string,
slug: string,
onSuccess: Function, onSuccess: Function,
authenticityToken: string, authenticityToken: string,
) { ) {
dispatch(updateBoard(id, name, description, authenticityToken)).then(res => { dispatch(updateBoard(id, name, description, slug, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.OK) onSuccess(); if (res && res.status === HttpStatus.OK) {
onSuccess();
window.location.reload();
}
}); });
}, },
@@ -54,7 +61,12 @@ const mapDispatchToProps = (dispatch: any) => ({
}, },
deleteBoard(id: number, authenticityToken: string) { deleteBoard(id: number, authenticityToken: string) {
dispatch(deleteBoard(id, authenticityToken)); dispatch(deleteBoard(id, authenticityToken)).then(res => {
console.log(res);
if (res && res.status === HttpStatus.Accepted) {
window.location.reload();
}
});
}, },
}); });

View File

@@ -2,6 +2,7 @@ interface IBoard {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
slug?: string;
} }
export default IBoard; export default IBoard;

View File

@@ -1,6 +1,7 @@
interface IPost { interface IPost {
id: number; id: number;
title: string; title: string;
slug?: string;
description?: string; description?: string;
boardId: number; boardId: number;
postStatusId?: number; postStatusId?: number;

View File

@@ -2,6 +2,7 @@ interface IBoardJSON {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
slug?: string;
} }
export default IBoardJSON; export default IBoardJSON;

View File

@@ -1,6 +1,7 @@
interface IPostJSON { interface IPostJSON {
id: number; id: number;
title: string; title: string;
slug?: string;
description?: string; description?: string;
board_id: number; board_id: number;
post_status_id?: number; post_status_id?: number;

View File

@@ -63,6 +63,7 @@ const boardsReducer = (
id: board.id, id: board.id,
name: board.name, name: board.name,
description: board.description, description: board.description,
slug: board.slug,
})), })),
areLoading: false, areLoading: false,
error: '', error: '',

View File

@@ -13,6 +13,7 @@ import IPost from '../interfaces/IPost';
const initialState: IPost = { const initialState: IPost = {
id: 0, id: 0,
title: '', title: '',
slug: null,
description: null, description: null,
boardId: 0, boardId: 0,
postStatusId: null, postStatusId: null,
@@ -37,6 +38,7 @@ const postReducer = (
return { return {
id: action.post.id, id: action.post.id,
title: action.post.title, title: action.post.title,
slug: action.post.slug,
description: action.post.description, description: action.post.description,
boardId: action.post.board_id, boardId: action.post.board_id,
postStatusId: action.post.post_status_id, postStatusId: action.post.post_status_id,

View File

@@ -1,9 +1,19 @@
class Board < ApplicationRecord class Board < ApplicationRecord
include TenantOwnable include TenantOwnable
include Orderable include Orderable
extend FriendlyId
has_many :posts, dependent: :destroy has_many :posts, dependent: :destroy
before_save :sanitize_slug
validates :name, presence: true, uniqueness: { scope: :tenant_id } validates :name, presence: true, uniqueness: { scope: :tenant_id }
validates :description, length: { in: 0..1024 }, allow_nil: true validates :description, length: { in: 0..1024 }, allow_nil: true
friendly_id :name, use: :slugged
def sanitize_slug
self.slug = self.slug.parameterize
self.slug = nil if self.slug.blank? # friendly_id will generate a slug if it's nil
end
end end

View File

@@ -1,8 +1,10 @@
class OAuth < ApplicationRecord class OAuth < ApplicationRecord
include TenantOwnable
include ApplicationHelper include ApplicationHelper
include Rails.application.routes.url_helpers include Rails.application.routes.url_helpers
include TenantOwnable
extend FriendlyId
has_many :tenant_default_o_auths, dependent: :destroy has_many :tenant_default_o_auths, dependent: :destroy
attr_accessor :state attr_accessor :state
@@ -17,6 +19,8 @@ class OAuth < ApplicationRecord
validates :scope, presence: true validates :scope, presence: true
validates :json_user_email_path, presence: true validates :json_user_email_path, presence: true
friendly_id :generate_random_slug, use: :slugged
def is_default? def is_default?
tenant_id == nil tenant_id == nil
end end
@@ -27,9 +31,9 @@ class OAuth < ApplicationRecord
# for this reason, we don't preprend tenant subdomain # for this reason, we don't preprend tenant subdomain
# but rather use the "login" subdomain # but rather use the "login" subdomain
if self.is_default? if self.is_default?
get_url_for(method(:o_auth_callback_url), resource: id, disallow_custom_domain: true, options: { subdomain: "login", host: Rails.application.base_url }) get_url_for(method(:o_auth_callback_url), resource: self, disallow_custom_domain: true, options: { subdomain: "login", host: Rails.application.base_url })
else else
get_url_for(method(:o_auth_callback_url), resource: id, disallow_custom_domain: true) get_url_for(method(:o_auth_callback_url), resource: self, disallow_custom_domain: true)
end end
end end
@@ -46,6 +50,14 @@ class OAuth < ApplicationRecord
is_default? and tenant_default_o_auths.exists? is_default? and tenant_default_o_auths.exists?
end end
def generate_random_slug
loop do
self.slug = SecureRandom.hex(8)
break unless self.class.exists?(slug: slug)
end
slug
end
class << self class << self
# returns all tenant-specific o_auths plus all default o_auths that are enabled site-wide # returns all tenant-specific o_auths plus all default o_auths that are enabled site-wide
def include_all_defaults def include_all_defaults

View File

@@ -1,5 +1,6 @@
class Post < ApplicationRecord class Post < ApplicationRecord
include TenantOwnable include TenantOwnable
extend FriendlyId
belongs_to :board belongs_to :board
belongs_to :user belongs_to :user
@@ -15,6 +16,8 @@ class Post < ApplicationRecord
paginates_per Rails.application.posts_per_page paginates_per Rails.application.posts_per_page
friendly_id :title, use: :slugged
class << self class << self
def find_with_post_status_in(post_statuses) def find_with_post_status_in(post_statuses)
where(post_status_id: post_statuses.pluck(:id)) where(post_status_id: post_statuses.pluck(:id))

View File

@@ -0,0 +1,107 @@
# FriendlyId Global Configuration
#
# Use this to set up shared configuration options for your entire application.
# Any of the configuration options shown here can also be applied to single
# models by passing arguments to the `friendly_id` class method or defining
# methods in your model.
#
# To learn more, check out the guide:
#
# http://norman.github.io/friendly_id/file.Guide.html
FriendlyId.defaults do |config|
# ## Reserved Words
#
# Some words could conflict with Rails's routes when used as slugs, or are
# undesirable to allow as slugs. Edit this list as needed for your app.
config.use :reserved
config.reserved_words = %w[new edit index session login logout users admin
stylesheets assets javascripts images]
# This adds an option to treat reserved words as conflicts rather than exceptions.
# When there is no good candidate, a UUID will be appended, matching the existing
# conflict behavior.
config.treat_reserved_as_conflict = true
# ## Friendly Finders
#
# Uncomment this to use friendly finders in all models. By default, if
# you wish to find a record by its friendly id, you must do:
#
# MyModel.friendly.find('foo')
#
# If you uncomment this, you can do:
#
# MyModel.find('foo')
#
# This is significantly more convenient but may not be appropriate for
# all applications, so you must explicitly opt-in to this behavior. You can
# always also configure it on a per-model basis if you prefer.
#
# Something else to consider is that using the :finders addon boosts
# performance because it will avoid Rails-internal code that makes runtime
# calls to `Module.extend`.
#
# config.use :finders
#
# ## Slugs
#
# Most applications will use the :slugged module everywhere. If you wish
# to do so, uncomment the following line.
#
# config.use :slugged
#
# By default, FriendlyId's :slugged addon expects the slug column to be named
# 'slug', but you can change it if you wish.
#
# config.slug_column = 'slug'
#
# By default, slug has no size limit, but you can change it if you wish.
#
# config.slug_limit = 128
#
# When FriendlyId can not generate a unique ID from your base method, it appends
# a UUID, separated by a single dash. You can configure the character used as the
# separator. If you're upgrading from FriendlyId 4, you may wish to replace this
# with two dashes.
#
# config.sequence_separator = '-'
#
# Note that you must use the :slugged addon **prior** to the line which
# configures the sequence separator, or else FriendlyId will raise an undefined
# method error.
#
# ## Tips and Tricks
#
# ### Controlling when slugs are generated
#
# As of FriendlyId 5.0, new slugs are generated only when the slug field is
# nil, but if you're using a column as your base method can change this
# behavior by overriding the `should_generate_new_friendly_id?` method that
# FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave
# more like 4.0.
# Note: Use(include) Slugged module in the config if using the anonymous module.
# If you have `friendly_id :name, use: slugged` in the model, Slugged module
# is included after the anonymous module defined in the initializer, so it
# overrides the `should_generate_new_friendly_id?` method from the anonymous module.
#
# config.use :slugged
# config.use Module.new {
# def should_generate_new_friendly_id?
# slug.blank? || <your_column_name_here>_changed?
# end
# }
#
# FriendlyId uses Rails's `parameterize` method to generate slugs, but for
# languages that don't use the Roman alphabet, that's not usually sufficient.
# Here we use the Babosa library to transliterate Russian Cyrillic slugs to
# ASCII. If you use this, don't forget to add "babosa" to your Gemfile.
#
# config.use Module.new {
# def normalize_friendly_id(text)
# text.to_slug.normalize! :transliterations => [:russian, :latin]
# end
# }
end

View File

@@ -0,0 +1,7 @@
class AddSlugToPosts < ActiveRecord::Migration[6.1]
def change
add_column :posts, :slug, :string
add_index :posts, :slug, unique: true
end
end

View File

@@ -0,0 +1,6 @@
class ChangeSlugPostUniqueConstraint < ActiveRecord::Migration[6.1]
def change
remove_index :posts, :slug
add_index :posts, [:slug, :tenant_id], unique: true
end
end

View File

@@ -0,0 +1,7 @@
class AddSlugToOAuth < ActiveRecord::Migration[6.1]
def change
add_column :o_auths, :slug, :string
add_index :o_auths, [:slug, :tenant_id], unique: true
end
end

View File

@@ -0,0 +1,7 @@
class AddSlugToBoards < ActiveRecord::Migration[6.1]
def change
add_column :boards, :slug, :string
add_index :boards, [:slug, :tenant_id], unique: true
end
end

View File

@@ -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.define(version: 2024_03_21_171022) do ActiveRecord::Schema.define(version: 2024_04_04_161306) 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 "plpgsql" enable_extension "plpgsql"
@@ -22,7 +22,9 @@ ActiveRecord::Schema.define(version: 2024_03_21_171022) do
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.integer "order", null: false t.integer "order", null: false
t.bigint "tenant_id", null: false t.bigint "tenant_id", null: false
t.string "slug"
t.index ["name", "tenant_id"], name: "index_boards_on_name_and_tenant_id", unique: true t.index ["name", "tenant_id"], name: "index_boards_on_name_and_tenant_id", unique: true
t.index ["slug", "tenant_id"], name: "index_boards_on_slug_and_tenant_id", unique: true
t.index ["tenant_id"], name: "index_boards_on_tenant_id" t.index ["tenant_id"], name: "index_boards_on_tenant_id"
end end
@@ -80,7 +82,9 @@ ActiveRecord::Schema.define(version: 2024_03_21_171022) do
t.bigint "tenant_id" t.bigint "tenant_id"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.string "slug"
t.index ["name", "tenant_id"], name: "index_o_auths_on_name_and_tenant_id", unique: true t.index ["name", "tenant_id"], name: "index_o_auths_on_name_and_tenant_id", unique: true
t.index ["slug", "tenant_id"], name: "index_o_auths_on_slug_and_tenant_id", unique: true
t.index ["tenant_id"], name: "index_o_auths_on_tenant_id" t.index ["tenant_id"], name: "index_o_auths_on_tenant_id"
end end
@@ -118,8 +122,10 @@ ActiveRecord::Schema.define(version: 2024_03_21_171022) do
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "tenant_id", null: false t.bigint "tenant_id", null: false
t.string "slug"
t.index ["board_id"], name: "index_posts_on_board_id" t.index ["board_id"], name: "index_posts_on_board_id"
t.index ["post_status_id"], name: "index_posts_on_post_status_id" t.index ["post_status_id"], name: "index_posts_on_post_status_id"
t.index ["slug", "tenant_id"], name: "index_posts_on_slug_and_tenant_id", unique: true
t.index ["tenant_id"], name: "index_posts_on_tenant_id" t.index ["tenant_id"], name: "index_posts_on_tenant_id"
t.index ["user_id"], name: "index_posts_on_user_id" t.index ["user_id"], name: "index_posts_on_user_id"
end end