From 05138790521d2e35b8620d846abfc5464a242ca6 Mon Sep 17 00:00:00 2001 From: Damien Desandre Date: Tue, 17 Feb 2026 22:06:49 +0100 Subject: [PATCH] feat: Add configurable default post sort order Add an admin setting in General Settings to configure the default post sort order (Trending, Most Wanted, Recent, Most Discussed). Previously the frontend always fell back to "trending" regardless of any server-side configuration. Now it reads the tenant's defaultSort setting both server-side (in the handler) and client-side (in the PostsContainer component). Includes database migration to add default_sort column to tenants table. --- app/actions/tenant.go | 27 +++++++++---- app/handlers/admin.go | 1 + app/handlers/post.go | 8 +++- app/models/cmd/tenant.go | 15 +++---- app/models/entity/tenant.go | 37 +++++++++--------- app/services/sqlstore/dbEntities/tenant.go | 2 + app/services/sqlstore/postgres/tenant.go | 39 ++++++++++--------- migrations/202602170001_add_default_sort.sql | 4 ++ public/models/identity.ts | 1 + .../pages/GeneralSettings.page.tsx | 16 +++++++- .../pages/Home/components/PostsContainer.tsx | 6 +-- public/pages/Home/components/PostsSort.tsx | 3 +- public/services/actions/tenant.ts | 1 + 13 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 migrations/202602170001_add_default_sort.sql diff --git a/app/actions/tenant.go b/app/actions/tenant.go index 26bce8efc..7de4f239f 100644 --- a/app/actions/tenant.go +++ b/app/actions/tenant.go @@ -190,13 +190,14 @@ func (action *ResendSignUpEmail) GetKind() enum.EmailVerificationKind { // UpdateTenantSettings is the input model used to update tenant settings type UpdateTenantSettings struct { - Logo *dto.ImageUpload `json:"logo"` - Title string `json:"title"` - Invitation string `json:"invitation"` - WelcomeMessage string `json:"welcomeMessage"` - WelcomeHeader string `json:"welcomeHeader"` - Locale string `json:"locale"` - CNAME string `json:"cname" format:"lower"` + Logo *dto.ImageUpload `json:"logo"` + Title string `json:"title"` + Invitation string `json:"invitation"` + WelcomeMessage string `json:"welcomeMessage"` + WelcomeHeader string `json:"welcomeHeader"` + Locale string `json:"locale"` + CNAME string `json:"cname" format:"lower"` + DefaultSort string `json:"defaultSort"` } func NewUpdateTenantSettings() *UpdateTenantSettings { @@ -256,6 +257,18 @@ func (action *UpdateTenantSettings) Validate(ctx context.Context, user *entity.U result.AddFieldFailure("cname", messages...) } + validSorts := []string{"trending", "most-wanted", "most-discussed", "recent"} + sortValid := false + for _, s := range validSorts { + if action.DefaultSort == s { + sortValid = true + break + } + } + if !sortValid { + action.DefaultSort = "trending" + } + return result } diff --git a/app/handlers/admin.go b/app/handlers/admin.go index e21d3724a..9bd600552 100644 --- a/app/handlers/admin.go +++ b/app/handlers/admin.go @@ -66,6 +66,7 @@ func UpdateSettings() web.HandlerFunc { WelcomeHeader: action.WelcomeHeader, CNAME: action.CNAME, Locale: action.Locale, + DefaultSort: action.DefaultSort, }, ); err != nil { return c.Failure(err) diff --git a/app/handlers/post.go b/app/handlers/post.go index 5d2f837c5..24f5224d3 100644 --- a/app/handlers/post.go +++ b/app/handlers/post.go @@ -17,9 +17,14 @@ func Index() web.HandlerFunc { return func(c *web.Context) error { c.SetCanonicalURL("") + view := c.QueryParam("view") + if view == "" { + view = c.Tenant().DefaultSort + } + searchPosts := &query.SearchPosts{ Query: c.QueryParam("query"), - View: c.QueryParam("view"), + View: view, Limit: c.QueryParam("limit"), Tags: c.QueryParamAsArray("tags"), } @@ -77,6 +82,7 @@ func Index() web.HandlerFunc { return c.Page(http.StatusOK, web.Props{ Page: "Home/Home.page", + Title: "Feedback", Description: description, // Header: c.Tenant().WelcomeHeader, Data: data, diff --git a/app/models/cmd/tenant.go b/app/models/cmd/tenant.go index bd5cef599..0e7d32f93 100644 --- a/app/models/cmd/tenant.go +++ b/app/models/cmd/tenant.go @@ -27,13 +27,14 @@ type UpdateTenantEmailAuthAllowedSettings struct { } type UpdateTenantSettings struct { - Logo *dto.ImageUpload - Title string - Invitation string - WelcomeMessage string - WelcomeHeader string - CNAME string - Locale string + Logo *dto.ImageUpload + Title string + Invitation string + WelcomeMessage string + WelcomeHeader string + CNAME string + Locale string + DefaultSort string } type UpdateTenantAdvancedSettings struct { diff --git a/app/models/entity/tenant.go b/app/models/entity/tenant.go index 064714600..590819f53 100644 --- a/app/models/entity/tenant.go +++ b/app/models/entity/tenant.go @@ -6,24 +6,25 @@ import ( // Tenant represents a tenant type Tenant struct { - ID int `json:"id"` - Name string `json:"name"` - Subdomain string `json:"subdomain"` - Invitation string `json:"invitation"` - WelcomeMessage string `json:"welcomeMessage"` - WelcomeHeader string `json:"welcomeHeader"` - CNAME string `json:"cname"` - Status enum.TenantStatus `json:"status"` - Locale string `json:"locale"` - IsPrivate bool `json:"isPrivate"` - LogoBlobKey string `json:"logoBlobKey"` - CustomCSS string `json:"-"` - AllowedSchemes string `json:"allowedSchemes"` - IsEmailAuthAllowed bool `json:"isEmailAuthAllowed"` - IsFeedEnabled bool `json:"isFeedEnabled"` - PreventIndexing bool `json:"preventIndexing"` - IsModerationEnabled bool `json:"isModerationEnabled"` - HasCommercialFeatures bool `json:"hasCommercialFeatures"` + ID int `json:"id"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + Invitation string `json:"invitation"` + WelcomeMessage string `json:"welcomeMessage"` + WelcomeHeader string `json:"welcomeHeader"` + CNAME string `json:"cname"` + Status enum.TenantStatus `json:"status"` + Locale string `json:"locale"` + IsPrivate bool `json:"isPrivate"` + LogoBlobKey string `json:"logoBlobKey"` + CustomCSS string `json:"-"` + AllowedSchemes string `json:"allowedSchemes"` + IsEmailAuthAllowed bool `json:"isEmailAuthAllowed"` + IsFeedEnabled bool `json:"isFeedEnabled"` + PreventIndexing bool `json:"preventIndexing"` + IsModerationEnabled bool `json:"isModerationEnabled"` + HasCommercialFeatures bool `json:"hasCommercialFeatures"` + DefaultSort string `json:"defaultSort"` } func (t *Tenant) IsDisabled() bool { diff --git a/app/services/sqlstore/dbEntities/tenant.go b/app/services/sqlstore/dbEntities/tenant.go index dad5671d7..a377de2ca 100644 --- a/app/services/sqlstore/dbEntities/tenant.go +++ b/app/services/sqlstore/dbEntities/tenant.go @@ -27,6 +27,7 @@ type Tenant struct { IsModerationEnabled bool `db:"is_moderation_enabled"` IsPro bool `db:"is_pro"` HasPaddleSubscription bool `db:"has_paddle_subscription"` + DefaultSort string `db:"default_sort"` } func (t *Tenant) ToModel() *entity.Tenant { @@ -63,6 +64,7 @@ func (t *Tenant) ToModel() *entity.Tenant { PreventIndexing: t.PreventIndexing, IsModerationEnabled: t.IsModerationEnabled, HasCommercialFeatures: hasCommercialFeatures, + DefaultSort: t.DefaultSort, } return tenant diff --git a/app/services/sqlstore/postgres/tenant.go b/app/services/sqlstore/postgres/tenant.go index 657c83a23..9fd15994a 100644 --- a/app/services/sqlstore/postgres/tenant.go +++ b/app/services/sqlstore/postgres/tenant.go @@ -80,8 +80,8 @@ func updateTenantSettings(ctx context.Context, c *cmd.UpdateTenantSettings) erro c.Logo.BlobKey = "" } - query := "UPDATE tenants SET name = $1, invitation = $2, welcome_message = $3, welcome_header = $4, cname = $5, logo_bkey = $6, locale = $7 WHERE id = $8" - _, err := trx.Execute(query, c.Title, c.Invitation, c.WelcomeMessage, c.WelcomeHeader, c.CNAME, c.Logo.BlobKey, c.Locale, tenant.ID) + query := "UPDATE tenants SET name = $1, invitation = $2, welcome_message = $3, welcome_header = $4, cname = $5, logo_bkey = $6, locale = $7, default_sort = $8 WHERE id = $9" + _, err := trx.Execute(query, c.Title, c.Invitation, c.WelcomeMessage, c.WelcomeHeader, c.CNAME, c.Logo.BlobKey, c.Locale, c.DefaultSort, tenant.ID) if err != nil { return errors.Wrap(err, "failed update tenant settings") } @@ -91,6 +91,7 @@ func updateTenantSettings(ctx context.Context, c *cmd.UpdateTenantSettings) erro tenant.CNAME = c.CNAME tenant.WelcomeMessage = c.WelcomeMessage tenant.WelcomeHeader = c.WelcomeHeader + tenant.DefaultSort = c.DefaultSort return nil }) @@ -189,8 +190,8 @@ func createTenant(ctx context.Context, c *cmd.CreateTenant) error { var id int err := trx.Get(&id, - `INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing, is_moderation_enabled) - VALUES ($1, $2, $3, '', '', '', $4, false, '', '', $5, true, true, true, false) + `INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing, is_moderation_enabled, default_sort) + VALUES ($1, $2, $3, '', '', '', $4, false, '', '', $5, true, true, true, false, 'trending') RETURNING id`, c.Name, c.Subdomain, now, c.Status, env.Config.Locale) if err != nil { return err @@ -207,13 +208,13 @@ func getFirstTenant(ctx context.Context, q *query.GetFirstTenant) error { return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { tenant := dbEntities.Tenant{} - err := trx.Get(&tenant, ` - SELECT t.id, t.name, t.subdomain, t.cname, t.invitation, t.locale, t.welcome_message, t.welcome_header, t.status, t.is_private, t.logo_bkey, t.custom_css, t.allowed_schemes, t.is_email_auth_allowed, t.is_feed_enabled, t.is_moderation_enabled, t.prevent_indexing, t.is_pro, - (b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription - FROM tenants t - LEFT JOIN tenants_billing b ON b.tenant_id = t.id - ORDER BY t.id LIMIT 1 - `) + err := trx.Get(&tenant, ` + SELECT t.id, t.name, t.subdomain, t.cname, t.invitation, t.locale, t.welcome_message, t.welcome_header, t.status, t.is_private, t.logo_bkey, t.custom_css, t.allowed_schemes, t.is_email_auth_allowed, t.is_feed_enabled, t.is_moderation_enabled, t.prevent_indexing, t.is_pro, t.default_sort, + (b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription + FROM tenants t + LEFT JOIN tenants_billing b ON b.tenant_id = t.id + ORDER BY t.id LIMIT 1 + `) if err != nil { return errors.Wrap(err, "failed to get first tenant") } @@ -227,14 +228,14 @@ func getTenantByDomain(ctx context.Context, q *query.GetTenantByDomain) error { return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { tenant := dbEntities.Tenant{} - err := trx.Get(&tenant, ` - SELECT t.id, t.name, t.subdomain, t.cname, t.invitation, t.locale, t.welcome_message, t.welcome_header, t.status, t.is_private, t.logo_bkey, t.custom_css, t.allowed_schemes, t.is_email_auth_allowed, t.is_feed_enabled, t.is_moderation_enabled, t.prevent_indexing, t.is_pro, - (b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription - FROM tenants t - LEFT JOIN tenants_billing b ON b.tenant_id = t.id - WHERE t.subdomain = $1 OR t.subdomain = $2 OR t.cname = $3 - ORDER BY t.cname DESC - `, env.Subdomain(q.Domain), q.Domain, q.Domain) + err := trx.Get(&tenant, ` + SELECT t.id, t.name, t.subdomain, t.cname, t.invitation, t.locale, t.welcome_message, t.welcome_header, t.status, t.is_private, t.logo_bkey, t.custom_css, t.allowed_schemes, t.is_email_auth_allowed, t.is_feed_enabled, t.is_moderation_enabled, t.prevent_indexing, t.is_pro, t.default_sort, + (b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription + FROM tenants t + LEFT JOIN tenants_billing b ON b.tenant_id = t.id + WHERE t.subdomain = $1 OR t.subdomain = $2 OR t.cname = $3 + ORDER BY t.cname DESC + `, env.Subdomain(q.Domain), q.Domain, q.Domain) if err != nil { return errors.Wrap(err, "failed to get tenant with domain '%s'", q.Domain) } diff --git a/migrations/202602170001_add_default_sort.sql b/migrations/202602170001_add_default_sort.sql new file mode 100644 index 000000000..7e4e7f47a --- /dev/null +++ b/migrations/202602170001_add_default_sort.sql @@ -0,0 +1,4 @@ +ALTER TABLE tenants ADD default_sort VARCHAR(50) NULL; +UPDATE tenants SET default_sort = 'trending'; +ALTER TABLE tenants ALTER COLUMN default_sort SET NOT NULL; +ALTER TABLE tenants ALTER COLUMN default_sort SET DEFAULT 'trending'; diff --git a/public/models/identity.ts b/public/models/identity.ts index 582e1895e..eb8b9f98f 100644 --- a/public/models/identity.ts +++ b/public/models/identity.ts @@ -15,6 +15,7 @@ export interface Tenant { isFeedEnabled: boolean isModerationEnabled: boolean hasCommercialFeatures: boolean + defaultSort: string } export enum TenantStatus { diff --git a/public/pages/Administration/pages/GeneralSettings.page.tsx b/public/pages/Administration/pages/GeneralSettings.page.tsx index 1a44518fa..c021f82ad 100644 --- a/public/pages/Administration/pages/GeneralSettings.page.tsx +++ b/public/pages/Administration/pages/GeneralSettings.page.tsx @@ -16,10 +16,11 @@ const GeneralSettingsPage = () => { const [logo, setLogo] = useState(undefined) const [cname, setCNAME] = useState(fider.session.tenant.cname) const [locale, setLocale] = useState(fider.session.tenant.locale) + const [defaultSort, setDefaultSort] = useState(fider.session.tenant.defaultSort) const [error, setError] = useState(undefined) const handleSave = async (e: ButtonClickEvent) => { - const result = await actions.updateTenantSettings({ title, cname, welcomeMessage, welcomeHeader, invitation, logo, locale }) + const result = await actions.updateTenantSettings({ title, cname, welcomeMessage, welcomeHeader, invitation, logo, locale, defaultSort }) if (result.ok) { e.preventEnable() location.href = `/` @@ -143,6 +144,19 @@ const GeneralSettingsPage = () => { )} +