diff --git a/client/containers/App/paths.ts b/client/containers/App/paths.ts
index 6b9a6a28de..d943db19d8 100644
--- a/client/containers/App/paths.ts
+++ b/client/containers/App/paths.ts
@@ -33,6 +33,7 @@ import {
Pricing,
Pub,
Search,
+ Search2,
Signup,
SuperAdminDashboard,
User,
@@ -183,6 +184,11 @@ export default (viewData, locationData, chunkName) => {
hideNav: locationData.isBasePubPub,
hideFooter: true,
},
+ Search2: {
+ ActiveComponent: Search2,
+ hideNav: locationData.isBasePubPub,
+ hideFooter: true,
+ },
Signup: {
ActiveComponent: Signup,
hideNav: true,
diff --git a/client/containers/Search2/Search2.tsx b/client/containers/Search2/Search2.tsx
new file mode 100644
index 0000000000..1b1ac70819
--- /dev/null
+++ b/client/containers/Search2/Search2.tsx
@@ -0,0 +1,489 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+import {
+ Button,
+ Checkbox,
+ Classes,
+ InputGroup,
+ NonIdealState,
+ Spinner,
+ Tab,
+ Tabs,
+} from '@blueprintjs/core';
+
+import { Icon } from 'components';
+import { usePageContext, useThrottled } from 'utils/hooks';
+import { getResizedUrl } from 'utils/images';
+import { generatePubBackground } from 'utils/pubs';
+
+import './search2.scss';
+
+/* ---------- types ---------- */
+type PubResult = {
+ id: string;
+ title: string;
+ slug: string;
+ avatar: string | null;
+ description: string | null;
+ byline: string | null;
+ communityId: string;
+ communityTitle: string;
+ communitySlug: string;
+ communityDomain: string | null;
+ communityAvatar: string | null;
+ communityAccentColorDark: string | null;
+ communityAccentColorLight: string | null;
+ communityHeaderLogo: string | null;
+ communityTextColor: string | null;
+};
+
+type CommunityResult = {
+ id: string;
+ title: string;
+ subdomain: string;
+ domain: string | null;
+ description: string | null;
+ avatar: string | null;
+ accentColorDark: string | null;
+ headerLogo: string | null;
+ pubCount: number;
+};
+
+type AuthorFacet = { name: string; count: number };
+type SearchMode = 'pubs' | 'communities';
+type FieldKey = 'title' | 'description' | 'byline' | 'content';
+
+const ALL_FIELDS: { key: FieldKey; label: string }[] = [
+ { key: 'title', label: 'Title' },
+ { key: 'description', label: 'Description' },
+ { key: 'byline', label: 'Authors' },
+ { key: 'content', label: 'Full text' },
+];
+
+const PAGE_SIZE = 20;
+
+/* ---------- helpers ---------- */
+const getSearchPath = (query: string, page: number, mode: string) => {
+ const params = new URLSearchParams();
+ if (query) params.append('q', query);
+ if (page > 0) params.append('page', String(page + 1));
+ if (mode !== 'pubs') params.append('mode', mode);
+ const qs = params.toString();
+ return `/search2${qs ? `?${qs}` : ''}`;
+};
+
+const updateHistory = (query: string, page: number, mode: string) => {
+ window.history.replaceState({}, '', getSearchPath(query, page, mode));
+};
+
+const getCommunityUrl = (r: { communityDomain: string | null; communitySlug: string }) =>
+ r.communityDomain ? `https://${r.communityDomain}` : `https://${r.communitySlug}.pubpub.org`;
+
+/* ---------- Pub result row (matches /search style) ---------- */
+const PubResultRow = ({ item, isBasePubPub }: { item: PubResult; isBasePubPub: boolean }) => {
+ const communityUrl = getCommunityUrl(item);
+ const link = `${communityUrl}/pub/${item.slug}`;
+ const resizedBanner = getResizedUrl(item.avatar, 'inside', 800);
+ const resizedCommunityLogo = getResizedUrl(item.communityAvatar, 'inside', 125, 35);
+ const bannerStyle = item.avatar
+ ? { backgroundImage: `url("${resizedBanner}")` }
+ : { background: generatePubBackground(item.id) };
+
+ return (
+
+
+
+
+ {item.byline &&
{item.byline}
}
+ {item.description &&
{item.description}
}
+
+
+ );
+};
+
+/* ---------- Community result row ---------- */
+const CommunityResultRow = ({ item }: { item: CommunityResult }) => {
+ const url = item.domain ? `https://${item.domain}` : `https://${item.subdomain}.pubpub.org`;
+ const resizedAvatar = getResizedUrl(item.avatar, 'inside', 200);
+ return (
+
+
+
+
+ {item.description &&
{item.description}
}
+
+
+
+ {item.pubCount} published pub{item.pubCount !== 1 ? 's' : ''}
+
+
+
+
+ );
+};
+
+/* ---------- Sidebar ---------- */
+const SearchSidebar = ({
+ fields,
+ onFieldToggle,
+ authors,
+ activeAuthor,
+ onAuthorClick,
+}: {
+ fields: Set;
+ onFieldToggle: (f: FieldKey) => void;
+ authors: AuthorFacet[];
+ activeAuthor: string | null;
+ onAuthorClick: (name: string | null) => void;
+}) => (
+
+);
+
+/* ---------- Main component ---------- */
+const Search2 = () => {
+ const { locationData, communityData } = usePageContext();
+ const isBasePubPub = locationData.isBasePubPub;
+
+ const [searchQuery, setSearchQuery] = useState(locationData.query.q || '');
+ const [results, setResults] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [isLoading, setIsLoading] = useState(!!locationData.query.q);
+ const [page, setPage] = useState(
+ locationData.query.page ? Number(locationData.query.page) - 1 : 0,
+ );
+ const initialMode = locationData.query.mode || 'pubs';
+ const [mode, setMode] = useState(
+ initialMode === 'communities' && isBasePubPub ? 'communities' : 'pubs',
+ );
+
+ // Facet state
+ const [fields, setFields] = useState>(
+ new Set(['title', 'description', 'byline'] as FieldKey[]),
+ );
+ const [authorFacets, setAuthorFacets] = useState([]);
+ const [activeAuthor, setActiveAuthor] = useState(null);
+ const [sidebarOpen, setSidebarOpen] = useState(true);
+
+ const throttledQuery = useThrottled(searchQuery, 400, false);
+ const inputRef = useRef(null);
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ const input = inputRef.current;
+ if (input) {
+ input.focus();
+ const old = input.value;
+ input.value = '';
+ input.value = old;
+ }
+ }, []);
+
+ const handleFieldToggle = useCallback((key: FieldKey) => {
+ setFields((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) {
+ if (next.size > 1) next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ setPage(0);
+ }, []);
+
+ const handleAuthorClick = useCallback((name: string | null) => {
+ setActiveAuthor(name);
+ setPage(0);
+ }, []);
+
+ const doSearch = useCallback(
+ async (q: string, p: number, m: string, f: Set, auth: string | null) => {
+ if (!q.trim()) {
+ setResults([]);
+ setTotal(0);
+ setAuthorFacets([]);
+ setIsLoading(false);
+ return;
+ }
+
+ if (abortRef.current) abortRef.current.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+ setIsLoading(true);
+
+ const params = new URLSearchParams({
+ q: q.trim(),
+ mode: m,
+ page: String(p),
+ limit: String(PAGE_SIZE),
+ });
+
+ if (m === 'pubs') {
+ const fieldArr = Array.from(f);
+ params.append('fields', fieldArr.join(','));
+ if (auth) params.append('author', auth);
+ if (!isBasePubPub) params.append('communityId', communityData.id);
+ }
+
+ try {
+ const resp = await fetch(`/api/search2?${params}`, {
+ signal: controller.signal,
+ credentials: 'include',
+ });
+ if (!resp.ok) throw new Error('Search failed');
+ const data = await resp.json();
+ if (!controller.signal.aborted) {
+ setResults(data.results);
+ setTotal(data.total);
+ if (data.facets?.authors) setAuthorFacets(data.facets.authors);
+ setIsLoading(false);
+ }
+ } catch (err: any) {
+ if (err.name !== 'AbortError') {
+ setIsLoading(false);
+ setResults([]);
+ setTotal(0);
+ }
+ }
+ },
+ [isBasePubPub, communityData.id],
+ );
+
+ useEffect(() => {
+ setActiveAuthor(null);
+ setPage(0);
+ }, [throttledQuery]);
+
+ useEffect(() => {
+ doSearch(throttledQuery, page, mode, fields, activeAuthor);
+ updateHistory(throttledQuery, page, mode);
+ }, [throttledQuery, page, mode, fields, activeAuthor, doSearch]);
+
+ const numPages = Math.min(Math.ceil(total / PAGE_SIZE), 10);
+ const searchString = getSearchPath(throttledQuery, page, mode);
+ const showSidebar = mode === 'pubs' && sidebarOpen && !!throttledQuery.trim();
+ const pages = new Array(numPages).fill('');
+
+ return (
+
+
+
+
+
+
{
+ setSearchQuery(e.target.value);
+ setPage(0);
+ }}
+ rightElement={isLoading ? : undefined}
+ inputRef={inputRef as any}
+ />
+
+
+
+
+
+ {
+ setMode(nextMode as SearchMode);
+ setPage(0);
+ setResults([]);
+ }}
+ selectedTabId={mode}
+ large={true}
+ animate={false}
+ >
+
+ {isBasePubPub && }
+
+ {mode === 'pubs' && throttledQuery.trim() && (
+
+ )}
+
+
+
+
+
+
+ {showSidebar && (
+
+ )}
+
+ {/* Active filter summary */}
+ {!!results.length && (activeAuthor || numPages > 1) && (
+
+ {total} result{total !== 1 ? 's' : ''}
+ {activeAuthor && (
+ <>
+ {' '}
+ by {activeAuthor}
+ >
+ )}
+ {numPages > 1 && ` · Page ${page + 1} of ${numPages}`}
+
+ )}
+
+ {/* No results */}
+ {!results.length && searchQuery && !isLoading && (
+
+ )}
+
+ {/* Result rows */}
+ {!!results.length && (
+
+ {mode === 'pubs' &&
+ results.map((item: PubResult) => (
+
+ ))}
+ {mode === 'communities' &&
+ results.map((item: CommunityResult) => (
+
+ ))}
+
+ {/* Pagination */}
+ {numPages > 1 && (
+
+ {pages.map((_: any, index: number) => {
+ const key = `page-button-${index}`;
+ return (
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default Search2;
diff --git a/client/containers/Search2/search2.scss b/client/containers/Search2/search2.scss
new file mode 100644
index 0000000000..453090affd
--- /dev/null
+++ b/client/containers/Search2/search2.scss
@@ -0,0 +1,300 @@
+@use 'styles/vendor.scss';
+
+$bp: vendor.$bp-namespace;
+$accent: #5c7cfa;
+
+// Re-use the exact same #search-container styles from /search
+#search-container {
+ padding: 2em 0em;
+
+ .search-header {
+ display: flex;
+ align-items: center;
+ h2 {
+ flex: 1 1 auto;
+ font-weight: 800;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ a {
+ white-space: nowrap;
+ flex: 0 0 auto;
+ margin-left: 1em;
+ text-decoration: none;
+ }
+ }
+
+ .#{$bp}-input-group input {
+ margin-bottom: 1em;
+ font-size: 35px;
+ font-weight: 200;
+ height: 70px;
+ line-height: 70px;
+ }
+
+ .#{$bp}-input-action {
+ margin-top: 7px;
+ }
+
+ .#{$bp}-tabs {
+ border-bottom: 1px solid #ccc;
+ }
+
+ .#{$bp}-spinner {
+ width: 35px;
+ margin: 13px;
+ }
+
+ // ---- tabs + filter toggle row ----
+ .tabs-filter-row {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+
+ .#{$bp}-tabs {
+ flex: 1 1 auto;
+ }
+ }
+
+ .filter-toggle {
+ background: none;
+ border: 1px solid #dfe3e8;
+ border-bottom: none;
+ border-radius: 6px 6px 0 0;
+ padding: 6px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ color: #637381;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-bottom: -1px;
+ transition: background 0.12s, color 0.12s;
+ &:hover {
+ background: #f4f6f8;
+ color: #212b36;
+ }
+ }
+
+ // ---- result rows (same as /search) ----
+ .result {
+ margin-bottom: 3em;
+ display: flex;
+ font-size: 16px;
+ a {
+ text-decoration: none;
+ }
+ &.pubs {
+ .banner-image {
+ width: 100px;
+ height: 100px;
+ }
+ }
+ &.communities {
+ .banner-image {
+ width: 100px;
+ height: 100px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+ .banner-image {
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-color: #f0f0f0;
+ margin-right: 1em;
+ border-radius: 2px;
+ }
+ .content {
+ flex: 1 1 auto;
+ }
+ .title {
+ font-weight: 800;
+ display: flex;
+ svg {
+ margin: -5px 0px 0px 5px;
+ }
+ .pub-title {
+ flex: 1 1 auto;
+ font-size: 22px;
+ word-break: break-word;
+ }
+ .community-title {
+ max-width: 40%;
+ font-size: 14px;
+ margin-left: 1em;
+ a {
+ padding: 0.5em;
+ white-space: nowrap;
+ border-radius: 2px;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+ .byline {
+ font-style: italic;
+ margin: 0.5em 0em;
+ }
+ .community-meta {
+ font-size: 12px;
+ color: #919eab;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 0.5em;
+ }
+ }
+
+ // ---- two-column layout ----
+ .search2-layout {
+ display: flex;
+ gap: 32px;
+ padding-top: 1.5em;
+
+ &.with-sidebar {
+ .search2-results {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+ }
+
+ .search2-results {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .search2-count {
+ font-size: 13px;
+ color: #919eab;
+ margin: 0 0 1.5em;
+ font-weight: 500;
+ strong {
+ color: #454f5b;
+ }
+ }
+
+ // ---- sidebar ----
+ .search2-sidebar {
+ flex: 0 0 200px;
+ width: 200px;
+ padding-top: 4px;
+ }
+
+ .sidebar-section {
+ margin-bottom: 24px;
+ }
+
+ .sidebar-heading {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #919eab;
+ margin: 0 0 10px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .sidebar-clear {
+ background: none;
+ border: none;
+ font-size: 11px;
+ font-weight: 600;
+ color: $accent;
+ cursor: pointer;
+ text-transform: none;
+ letter-spacing: 0;
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .sidebar-checkbox {
+ margin-bottom: 6px;
+ .#{$bp}-control-indicator {
+ border-radius: 4px;
+ }
+ }
+
+ .sidebar-facet-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ .facet-btn {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ background: none;
+ border: none;
+ padding: 5px 8px;
+ border-radius: 6px;
+ font-size: 13px;
+ color: #454f5b;
+ cursor: pointer;
+ transition: background 0.1s;
+ text-align: left;
+ &:hover {
+ background: #f4f6f8;
+ }
+ &.active {
+ background: rgba($accent, 0.08);
+ color: $accent;
+ font-weight: 600;
+ }
+ }
+
+ .facet-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-right: 8px;
+ }
+
+ .facet-count {
+ flex: 0 0 auto;
+ font-size: 11px;
+ color: #919eab;
+ background: #f4f6f8;
+ padding: 1px 6px;
+ border-radius: 10px;
+ font-weight: 600;
+ .active & {
+ background: rgba($accent, 0.12);
+ color: $accent;
+ }
+ }
+}
+
+// ---- responsive ----
+@media (max-width: 768px) {
+ #search-container {
+ .search2-layout.with-sidebar {
+ flex-direction: column;
+ }
+ .search2-sidebar {
+ flex: none;
+ width: 100%;
+ border-bottom: 1px solid #ebeef1;
+ padding-bottom: 16px;
+ margin-bottom: 8px;
+ }
+ .tabs-filter-row {
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+ }
+}
diff --git a/client/containers/index.ts b/client/containers/index.ts
index 4a56db26ab..83442f6d9c 100644
--- a/client/containers/index.ts
+++ b/client/containers/index.ts
@@ -34,6 +34,7 @@ export { default as PasswordReset } from './PasswordReset/PasswordReset';
export { default as Pricing } from './Pricing/Pricing';
export { default as Pub } from './Pub/Pub';
export { default as Search } from './Search/Search';
+export { default as Search2 } from './Search2/Search2';
export { default as Signup } from './Signup/Signup';
export { default as SuperAdminDashboard } from './SuperAdminDashboard';
export { default as User } from './User/User';
diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml
index af0fea0874..05ae4843fa 100644
--- a/infra/docker-compose.dev.yml
+++ b/infra/docker-compose.dev.yml
@@ -8,15 +8,15 @@ services:
target: dev
env_file:
- .env.dev
- healthcheck:
- test:
- - CMD-SHELL
- - >
- curl -f http://localhost:3000
- interval: 10s
- retries: 5
- start_period: 120s
- timeout: 3s
+ # healthcheck:
+ # test:
+ # - CMD-SHELL
+ # - >
+ # curl -f http://localhost:3000
+ # interval: 10s
+ # retries: 5
+ # start_period: 120s
+ # timeout: 3s
environment:
IS_DUQDUQ: ""
NODE_ENV: development
@@ -49,8 +49,8 @@ services:
condition: service_started
rabbitmq:
condition: service_started
- app:
- condition: service_healthy
+ # app:
+ # condition: service_healthy
networks: [appnet]
volumes:
- ..:/app
@@ -81,32 +81,32 @@ services:
-c work_mem=16MB
-c maintenance_work_mem=512MB
volumes:
- - ./pgdata:/var/lib/postgresql/data
+ - pgdata:/var/lib/postgresql/data
ports:
- "${DB_PORT:-5439}:5432"
networks: [appnet]
- cron:
- build:
- context: ..
- dockerfile: Dockerfile
- target: dev
- env_file:
- - .env.dev
- environment:
- NODE_ENV: development
- command: ["pnpm", "run", "cron"]
- networks: [appnet]
- volumes:
- - ..:/app
- - /app/node_modules
- deploy:
- replicas: 1
- restart_policy:
- condition: any
+ # cron:
+ # build:
+ # context: ..
+ # dockerfile: Dockerfile
+ # target: dev
+ # env_file:
+ # - .env.dev
+ # environment:
+ # NODE_ENV: development
+ # command: ["pnpm", "run", "cron"]
+ # networks: [appnet]
+ # volumes:
+ # - ..:/app
+ # - /app/node_modules
+ # deploy:
+ # replicas: 1
+ # restart_policy:
+ # condition: any
networks:
appnet:
driver: bridge
volumes:
- # pgdata:
+ pgdata:
rabbitmqdata:
diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts
index 53be2d4829..772b4cec09 100644
--- a/server/apiRoutes.ts
+++ b/server/apiRoutes.ts
@@ -24,6 +24,7 @@ import { router as reviewRouter } from './review/api';
import { router as reviewerRouter } from './reviewer/api';
import { router as rssRouter } from './rss/api';
import { router as searchRouter } from './search/api';
+import { router as search2Router } from './search2/api';
import { router as signupRouter } from './signup/api';
import { router as spamTagRouter } from './spamTag/api';
import { router as submissionRouter } from './submission/api';
@@ -57,6 +58,7 @@ const apiRouter = Router()
.use(reviewerRouter)
.use(rssRouter)
.use(searchRouter)
+ .use(search2Router)
.use(signupRouter)
.use(spamTagRouter)
.use(subscribeRouter)
diff --git a/server/community/model.ts b/server/community/model.ts
index 1728cffcb5..8258896955 100644
--- a/server/community/model.ts
+++ b/server/community/model.ts
@@ -36,9 +36,11 @@ import {
} from '../models';
@Table
+// GIN index on searchVector is created in searchTriggers.ts
+// after the column is added via ALTER TABLE.
export class Community extends Model<
- InferAttributes,
- InferCreationAttributes
+ InferAttributes,
+ InferCreationAttributes
> {
public declare toJSON: (this: M) => SerializedModel;
@@ -227,6 +229,14 @@ export class Community extends Model<
@Column(DataType.UUID)
declare spamTagId: string | null;
+ /**
+ * Pre-computed tsvector for full-text search.
+ * Weights: A=title, B=description.
+ * Updated by Postgres trigger on Communities.
+ */
+ @Column(DataType.TSVECTOR)
+ declare searchVector: any | null;
+
@Column(DataType.UUID)
declare organizationId: string | null;
diff --git a/server/pub/model.ts b/server/pub/model.ts
index 74186d8e2e..f5fe69eebb 100644
--- a/server/pub/model.ts
+++ b/server/pub/model.ts
@@ -44,9 +44,14 @@ import {
unique: true,
fields: ['communityId', 'slug'],
},
+ // GIN index on searchVector is created in searchTriggers.ts
+ // after the column is added via ALTER TABLE.
],
})
-export class Pub extends Model, InferCreationAttributes> {
+export class Pub extends Model<
+ InferAttributes,
+ InferCreationAttributes
+> {
public declare toJSON: (this: M) => SerializedModel;
@Default(DataType.UUIDV4)
@@ -135,6 +140,14 @@ export class Pub extends Model, InferCreationAttributes PubAttribution, { onDelete: 'CASCADE', as: 'attributions', foreignKey: 'pubId' })
declare attributions?: PubAttribution[];
diff --git a/server/pubAttribution/model.ts b/server/pubAttribution/model.ts
index 6a887bdd0e..8c0cf38b8e 100644
--- a/server/pubAttribution/model.ts
+++ b/server/pubAttribution/model.ts
@@ -16,7 +16,15 @@ import {
import { Pub, User } from '../models';
-@Table
+@Table({
+ indexes: [
+ {
+ name: 'pubattributions_pubid_isauthor_idx',
+ fields: ['pubId'],
+ where: { isAuthor: true },
+ },
+ ],
+})
export class PubAttribution extends Model<
InferAttributes,
InferCreationAttributes
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 2b61caa0f2..1ba53fbbf5 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -44,6 +44,7 @@ import { router as pageRouter } from './page'; // Route: ['/', '/:slug']
import { router as passwordResetRouter } from './passwordReset'; // Route: ['/password-reset', '/password-reset/:resetHash/:slug']
import { router as robotsRouter } from './robots'; // Route: /robots.txt
import { router as searchRouter } from './search'; // Route: '/search'
+import { router as search2Router } from './search2'; // Route: '/search2'
import { router as signupRouter } from './signup'; // Route: '/signup'
import { router as sitemapRouter } from './sitemap'; // Route: /sitemap-*.xml
import { router as superAdminDashboardRouter } from './superAdminDashboard'; // Route: /superadmin
@@ -84,6 +85,7 @@ rootRouter
.use(authenticateRouter)
.use(legalRouter)
.use(searchRouter)
+ .use(search2Router)
.use(signupRouter)
.use(superAdminDashboardRouter)
.use(passwordResetRouter)
diff --git a/server/routes/search2.tsx b/server/routes/search2.tsx
new file mode 100644
index 0000000000..2175e8cff7
--- /dev/null
+++ b/server/routes/search2.tsx
@@ -0,0 +1,37 @@
+import type { InitialData } from 'types';
+
+import React from 'react';
+
+import { Router } from 'express';
+
+import { getCustomScriptsForCommunity } from 'server/customScript/queries';
+import Html from 'server/Html';
+import { handleErrors } from 'server/utils/errors';
+import { getInitialData } from 'server/utils/initData';
+import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr';
+
+export const router = Router();
+
+router.get('/search2', async (req, res, next) => {
+ try {
+ const initialData = await getInitialData(req);
+ const customScripts = await getCustomScriptsForCommunity(initialData.communityData.id);
+
+ return renderToNodeStream(
+ res,
+ ,
+ );
+ } catch (err) {
+ return handleErrors(req, res, next)(err);
+ }
+});
diff --git a/server/search2/api.ts b/server/search2/api.ts
new file mode 100644
index 0000000000..5f9df45b33
--- /dev/null
+++ b/server/search2/api.ts
@@ -0,0 +1,51 @@
+import { Router } from 'express';
+
+import { type SearchFields, searchCommunities, searchPubs } from './queries';
+
+export const router = Router();
+
+const ALL_FIELDS: SearchFields[] = ['title', 'description', 'byline', 'content'];
+
+router.get('/api/search2', async (req, res) => {
+ try {
+ const q = (req.query.q as string) || '';
+ const mode = (req.query.mode as string) || 'pubs';
+ const page = Math.max(0, parseInt(req.query.page as string, 10) || 0);
+ const limit = Math.min(50, Math.max(1, parseInt(req.query.limit as string, 10) || 20));
+ const offset = page * limit;
+ const communityId = req.query.communityId as string | undefined;
+ const author = (req.query.author as string) || undefined;
+
+ // Parse fields param: comma-separated, validated against known fields
+ let fields: SearchFields[] | undefined;
+ if (req.query.fields) {
+ const raw = (req.query.fields as string).split(',');
+ fields = raw.filter((f): f is SearchFields => ALL_FIELDS.includes(f as SearchFields));
+ if (fields.length === 0) fields = undefined;
+ }
+
+ if (!q.trim()) {
+ return res
+ .status(200)
+ .json({ results: [], total: 0, page, limit, facets: { authors: [] } });
+ }
+
+ if (mode === 'communities') {
+ const { results, total } = await searchCommunities(q, { limit, offset });
+ return res.status(200).json({ results, total, page, limit, facets: { authors: [] } });
+ }
+
+ // Default: pubs
+ const { results, total, facets } = await searchPubs(q, {
+ limit,
+ offset,
+ communityId,
+ fields,
+ author,
+ });
+ return res.status(200).json({ results, total, page, limit, facets });
+ } catch (err) {
+ console.error('Error in search2 API:', err);
+ return res.status(500).json({ error: 'Search failed' });
+ }
+});
diff --git a/server/search2/queries.ts b/server/search2/queries.ts
new file mode 100644
index 0000000000..150aec923d
--- /dev/null
+++ b/server/search2/queries.ts
@@ -0,0 +1,304 @@
+import { QueryTypes } from 'sequelize';
+
+import { sequelize } from 'server/sequelize';
+
+export type PubSearchResult = {
+ id: string;
+ title: string;
+ slug: string;
+ avatar: string | null;
+ description: string | null;
+ customPublishedAt: string | null;
+ communityId: string;
+ communityTitle: string;
+ communitySlug: string;
+ communityDomain: string | null;
+ communityAvatar: string | null;
+ communityAccentColorDark: string | null;
+ communityAccentColorLight: string | null;
+ communityHeaderLogo: string | null;
+ communityTextColor: string | null;
+ byline: string | null;
+ rank: number;
+};
+
+export type CommunitySearchResult = {
+ id: string;
+ title: string;
+ subdomain: string;
+ domain: string | null;
+ description: string | null;
+ avatar: string | null;
+ accentColorDark: string | null;
+ headerLogo: string | null;
+ pubCount: number;
+ rank: number;
+};
+
+export type SearchFields = 'title' | 'description' | 'byline' | 'content';
+
+type AuthorFacet = { name: string; count: number };
+
+/**
+ * Sanitize user input into a tsquery with prefix matching.
+ * e.g. "hello world" -> "hello:* & world:*"
+ */
+const buildTsQuery = (searchTerm: string): string | null => {
+ const sanitized = searchTerm.trim().replace(/[^\w\s-]/g, '');
+ const terms = sanitized.split(/\s+/).filter(Boolean);
+ if (terms.length === 0) return null;
+ return terms.map((w) => `${w}:*`).join(' & ');
+};
+
+/**
+ * Map user-selected field checkboxes to tsvector weight letters.
+ *
+ * title -> A
+ * description -> B
+ * byline -> C
+ * content -> D
+ *
+ * When all weights are selected we skip the ts_rank weight mask and just use
+ * the full searchVector. When a subset is selected we filter the tsquery with
+ * those weights so only matching lexemes in those sections contribute.
+ */
+const FIELD_WEIGHTS: Record = {
+ title: 'A',
+ description: 'B',
+ byline: 'C',
+ content: 'D',
+};
+
+const ALL_WEIGHT_LETTERS = 'ABCD';
+
+const getWeightMask = (fields?: SearchFields[]): string => {
+ if (!fields || fields.length === 0) return 'ABC'; // default: no content
+ const weights = fields.map((f) => FIELD_WEIGHTS[f]).join('');
+ return weights || 'ABC';
+};
+
+/**
+ * Search pubs using the pre-computed searchVector column (GIN-indexed).
+ *
+ * The searchVector is maintained by Postgres triggers and contains weighted
+ * tsvector data: A=title, B=description, C=byline, D=doc content.
+ *
+ * Field selection works by restricting the tsquery to specific weights.
+ * Only returns publicly released pubs. Excludes spam communities.
+ */
+export const searchPubs = async (
+ searchTerm: string,
+ {
+ limit = 20,
+ offset = 0,
+ communityId,
+ fields,
+ author,
+ }: {
+ limit?: number;
+ offset?: number;
+ communityId?: string;
+ fields?: SearchFields[];
+ author?: string;
+ } = {},
+): Promise<{ results: PubSearchResult[]; total: number; facets: { authors: AuthorFacet[] } }> => {
+ const tsQuery = buildTsQuery(searchTerm);
+ if (!tsQuery) return { results: [], total: 0, facets: { authors: [] } };
+
+ const weightMask = getWeightMask(fields);
+ const useWeightFilter = weightMask !== ALL_WEIGHT_LETTERS;
+
+ const communityFilter = communityId ? `AND p."communityId" = :communityId` : '';
+ const authorFilter = author
+ ? `AND EXISTS (
+ SELECT 1 FROM "PubAttributions" af
+ LEFT JOIN "Users" au ON au.id = af."userId"
+ WHERE af."pubId" = p.id AND af."isAuthor" = true
+ AND (af.name ILIKE :authorFilter OR au."fullName" ILIKE :authorFilter)
+ )`
+ : '';
+ const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`;
+
+ // When weight filtering is active, we strip the tsvector to only the
+ // selected weights before matching and ranking. Otherwise we use the
+ // full searchVector which is entirely covered by the GIN index.
+ const vectorExpr = useWeightFilter
+ ? `ts_filter(p."searchVector", '{${weightMask.split('').join(',')}}')`
+ : `p."searchVector"`;
+
+ const query = `
+ WITH pub_bylines AS (
+ SELECT pa."pubId",
+ string_agg(COALESCE(u."fullName", pa.name), ', ' ORDER BY pa."order") AS byline
+ FROM "PubAttributions" pa
+ LEFT JOIN "Users" u ON u.id = pa."userId"
+ WHERE pa."isAuthor" = true
+ AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL)
+ GROUP BY pa."pubId"
+ ),
+ matching_pubs AS (
+ SELECT
+ p.id, p.title, p.slug, p.avatar, p.description, p."customPublishedAt",
+ c.id AS "communityId",
+ c.title AS "communityTitle",
+ c.subdomain AS "communitySlug",
+ c.domain AS "communityDomain",
+ c.avatar AS "communityAvatar",
+ c."accentColorDark" AS "communityAccentColorDark",
+ c."accentColorLight" AS "communityAccentColorLight",
+ c."headerLogo" AS "communityHeaderLogo",
+ c."accentTextColor" AS "communityTextColor",
+ pb.byline,
+ ts_rank_cd(${vectorExpr}, to_tsquery('english', :tsQuery)) AS rank
+ FROM "Pubs" p
+ INNER JOIN "Communities" c ON c.id = p."communityId"
+ INNER JOIN "Releases" r_exists ON r_exists."pubId" = p.id
+ LEFT JOIN pub_bylines pb ON pb."pubId" = p.id
+ LEFT JOIN "SpamTags" st ON st.id = c."spamTagId"
+ WHERE (st.status IS NULL OR st.status != 'confirmed')
+ AND p."searchVector" IS NOT NULL
+ AND (
+ ${vectorExpr} @@ to_tsquery('english', :tsQuery)
+ OR p.title ILIKE :ilikeTerm
+ )
+ ${communityFilter}
+ ${authorFilter}
+ ),
+ deduped AS (
+ SELECT DISTINCT ON (id) * FROM matching_pubs
+ ),
+ facet_data AS (
+ SELECT json_agg(sub) AS facets FROM (
+ SELECT COALESCE(u."fullName", pa.name) AS name, count(DISTINCT pa."pubId")::int AS count
+ FROM "PubAttributions" pa
+ LEFT JOIN "Users" u ON u.id = pa."userId"
+ WHERE pa."pubId" IN (SELECT id FROM deduped)
+ AND pa."isAuthor" = true
+ AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL)
+ GROUP BY COALESCE(u."fullName", pa.name)
+ ORDER BY count DESC, name ASC
+ LIMIT 15
+ ) sub
+ )
+ SELECT
+ d.*,
+ count(*) OVER() AS total,
+ (SELECT facets FROM facet_data) AS "authorFacets"
+ FROM deduped d
+ ORDER BY rank DESC
+ LIMIT :limit
+ OFFSET :offset
+ `;
+
+ const results = await sequelize.query(query, {
+ replacements: {
+ tsQuery,
+ ilikeTerm,
+ limit,
+ offset,
+ ...(communityId ? { communityId } : {}),
+ ...(author ? { authorFilter: author } : {}),
+ },
+ type: QueryTypes.SELECT,
+ });
+
+ const total = results.length > 0 ? Number((results[0] as any).total) : 0;
+ const authorFacetsJson = results.length > 0 ? (results[0] as any).authorFacets : null;
+ const facets: { authors: AuthorFacet[] } = {
+ authors: Array.isArray(authorFacetsJson) ? authorFacetsJson : [],
+ };
+
+ return {
+ results: results.map((r: any) => ({
+ id: r.id,
+ title: r.title,
+ slug: r.slug,
+ avatar: r.avatar,
+ description: r.description,
+ customPublishedAt: r.customPublishedAt,
+ communityId: r.communityId,
+ communityTitle: r.communityTitle,
+ communitySlug: r.communitySlug,
+ communityDomain: r.communityDomain,
+ communityAvatar: r.communityAvatar,
+ communityAccentColorDark: r.communityAccentColorDark,
+ communityAccentColorLight: r.communityAccentColorLight,
+ communityHeaderLogo: r.communityHeaderLogo,
+ communityTextColor: r.communityTextColor,
+ byline: r.byline,
+ rank: r.rank,
+ })),
+ total,
+ facets,
+ };
+};
+
+/**
+ * Search communities using the pre-computed searchVector column (GIN-indexed).
+ * Excludes confirmed-spam communities.
+ */
+export const searchCommunities = async (
+ searchTerm: string,
+ { limit = 20, offset = 0 }: { limit?: number; offset?: number } = {},
+): Promise<{ results: CommunitySearchResult[]; total: number }> => {
+ const tsQuery = buildTsQuery(searchTerm);
+ if (!tsQuery) return { results: [], total: 0 };
+
+ const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`;
+
+ const query = `
+ WITH community_pub_counts AS (
+ SELECT p."communityId", count(DISTINCT p.id) AS pub_count
+ FROM "Pubs" p
+ INNER JOIN "Releases" r ON r."pubId" = p.id
+ GROUP BY p."communityId"
+ )
+ SELECT
+ c.id,
+ c.title,
+ c.subdomain,
+ c.domain,
+ c.description,
+ c.avatar,
+ c."accentColorDark",
+ c."headerLogo",
+ coalesce(cpc.pub_count, 0)::int AS "pubCount",
+ ts_rank_cd(c."searchVector", to_tsquery('english', :tsQuery)) AS rank,
+ count(*) OVER() AS total
+ FROM "Communities" c
+ LEFT JOIN "SpamTags" st ON st.id = c."spamTagId"
+ LEFT JOIN community_pub_counts cpc ON cpc."communityId" = c.id
+ WHERE (st.status IS NULL OR st.status != 'confirmed')
+ AND c."searchVector" IS NOT NULL
+ AND (
+ c."searchVector" @@ to_tsquery('english', :tsQuery)
+ OR c.title ILIKE :ilikeTerm
+ )
+ ORDER BY rank DESC
+ LIMIT :limit
+ OFFSET :offset
+ `;
+
+ const results = await sequelize.query(query, {
+ replacements: { tsQuery, ilikeTerm, limit, offset },
+ type: QueryTypes.SELECT,
+ });
+
+ const total = results.length > 0 ? Number((results[0] as any).total) : 0;
+
+ return {
+ results: results.map((r: any) => ({
+ id: r.id,
+ title: r.title,
+ subdomain: r.subdomain,
+ domain: r.domain,
+ description: r.description,
+ avatar: r.avatar,
+ accentColorDark: r.accentColorDark,
+ headerLogo: r.headerLogo,
+ pubCount: r.pubCount,
+ rank: r.rank,
+ })),
+ total,
+ };
+};
diff --git a/server/search2/searchTriggers.ts b/server/search2/searchTriggers.ts
new file mode 100644
index 0000000000..3a34b4ac1e
--- /dev/null
+++ b/server/search2/searchTriggers.ts
@@ -0,0 +1,238 @@
+/**
+ * Postgres triggers and functions for maintaining pre-computed tsvector columns
+ * on the Pubs and Communities tables. These are installed after sequelize.sync()
+ * so the columns/indexes exist before the triggers reference them.
+ *
+ * Weight mapping for Pubs:
+ * A = title
+ * B = description
+ * C = byline (aggregated from PubAttributions + Users)
+ * D = latest release doc content (extracted from ProseMirror JSON)
+ *
+ * Weight mapping for Communities:
+ * A = title
+ * B = description
+ *
+ * Triggers fire on:
+ * - Pubs INSERT/UPDATE of title or description
+ * - PubAttributions INSERT/UPDATE/DELETE (recalculates byline for affected pub)
+ * - Releases INSERT (new release = new doc content for the pub)
+ * - Communities INSERT/UPDATE of title or description
+ */
+
+import { sequelize } from 'server/sequelize';
+
+/**
+ * Install all search-related triggers and functions. Idempotent — uses
+ * CREATE OR REPLACE and IF NOT EXISTS throughout.
+ */
+export const installSearchTriggers = async () => {
+ // ---- Ensure searchVector columns exist (sync force:false won't add them) ----
+ await sequelize.query(`
+ ALTER TABLE "Pubs" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
+ `);
+ await sequelize.query(`
+ ALTER TABLE "Communities" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
+ `);
+
+ // ---- GIN indexes on searchVector columns ----
+ await sequelize.query(`
+ CREATE INDEX IF NOT EXISTS pubs_search_vector_idx
+ ON "Pubs" USING gin ("searchVector");
+ `);
+ await sequelize.query(`
+ CREATE INDEX IF NOT EXISTS communities_search_vector_idx
+ ON "Communities" USING gin ("searchVector");
+ `);
+
+ // ---- Helper: extract plain text from ProseMirror JSONB ----
+ await sequelize.query(`
+ CREATE OR REPLACE FUNCTION extract_doc_text(doc_content jsonb)
+ RETURNS text
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
+ WITH RECURSIVE nodes(node) AS (
+ SELECT doc_content
+ UNION ALL
+ SELECT jsonb_array_elements(nodes.node->'content')
+ FROM nodes
+ WHERE nodes.node->'content' IS NOT NULL
+ AND jsonb_typeof(nodes.node->'content') = 'array'
+ )
+ SELECT coalesce(string_agg(node->>'text', ' '), '')
+ FROM nodes
+ WHERE node->>'text' IS NOT NULL;
+ $$;
+ `);
+
+ // ---- Pub search vector update function ----
+ await sequelize.query(`
+ CREATE OR REPLACE FUNCTION pub_search_vector_update()
+ RETURNS trigger
+ LANGUAGE plpgsql AS $$
+ DECLARE
+ pub_row RECORD;
+ byline_text text;
+ doc_text text;
+ doc_id uuid;
+ BEGIN
+ -- Determine which pub to update
+ IF TG_TABLE_NAME = 'Pubs' THEN
+ pub_row := NEW;
+ ELSIF TG_TABLE_NAME = 'PubAttributions' THEN
+ IF TG_OP = 'DELETE' THEN
+ pub_row := OLD;
+ ELSE
+ pub_row := NEW;
+ END IF;
+ ELSIF TG_TABLE_NAME = 'Releases' THEN
+ pub_row := NEW;
+ END IF;
+
+ -- Get the full pub title/description (needed when triggered from related tables)
+ IF TG_TABLE_NAME != 'Pubs' THEN
+ SELECT INTO pub_row p.* FROM "Pubs" p WHERE p.id = pub_row."pubId";
+ IF NOT FOUND THEN
+ RETURN COALESCE(NEW, OLD);
+ END IF;
+ END IF;
+
+ -- Aggregate byline from PubAttributions + Users
+ SELECT coalesce(string_agg(coalesce(u."fullName", pa.name), ' '), '')
+ INTO byline_text
+ FROM "PubAttributions" pa
+ LEFT JOIN "Users" u ON u.id = pa."userId"
+ WHERE pa."pubId" = pub_row.id
+ AND pa."isAuthor" = true
+ AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL);
+
+ -- Get latest release doc content
+ SELECT r."docId" INTO doc_id
+ FROM "Releases" r
+ WHERE r."pubId" = pub_row.id
+ ORDER BY r."createdAt" DESC
+ LIMIT 1;
+
+ IF doc_id IS NOT NULL THEN
+ SELECT extract_doc_text(d.content) INTO doc_text
+ FROM "Docs" d WHERE d.id = doc_id;
+ END IF;
+
+ -- Update the search vector
+ UPDATE "Pubs" SET "searchVector" =
+ setweight(to_tsvector('english', coalesce(pub_row.title, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(pub_row.description, '')), 'B') ||
+ setweight(to_tsvector('english', coalesce(byline_text, '')), 'C') ||
+ setweight(to_tsvector('english', coalesce(doc_text, '')), 'D')
+ WHERE id = pub_row.id;
+
+ RETURN COALESCE(NEW, OLD);
+ END;
+ $$;
+ `);
+
+ // ---- Triggers on Pubs ----
+ await sequelize.query(`
+ DROP TRIGGER IF EXISTS pubs_search_vector_update ON "Pubs";
+ CREATE TRIGGER pubs_search_vector_update
+ AFTER INSERT OR UPDATE OF title, description ON "Pubs"
+ FOR EACH ROW
+ EXECUTE FUNCTION pub_search_vector_update();
+ `);
+
+ // ---- Triggers on PubAttributions ----
+ await sequelize.query(`
+ DROP TRIGGER IF EXISTS pubattributions_search_vector_update ON "PubAttributions";
+ CREATE TRIGGER pubattributions_search_vector_update
+ AFTER INSERT OR UPDATE OR DELETE ON "PubAttributions"
+ FOR EACH ROW
+ EXECUTE FUNCTION pub_search_vector_update();
+ `);
+
+ // ---- Triggers on Releases ----
+ await sequelize.query(`
+ DROP TRIGGER IF EXISTS releases_search_vector_update ON "Releases";
+ CREATE TRIGGER releases_search_vector_update
+ AFTER INSERT ON "Releases"
+ FOR EACH ROW
+ EXECUTE FUNCTION pub_search_vector_update();
+ `);
+
+ // ---- Community search vector update function ----
+ await sequelize.query(`
+ CREATE OR REPLACE FUNCTION community_search_vector_update()
+ RETURNS trigger
+ LANGUAGE plpgsql AS $$
+ BEGIN
+ NEW."searchVector" :=
+ setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(NEW.description, '')), 'B');
+ RETURN NEW;
+ END;
+ $$;
+ `);
+
+ // ---- Trigger on Communities (BEFORE so we can modify NEW directly) ----
+ await sequelize.query(`
+ DROP TRIGGER IF EXISTS communities_search_vector_update ON "Communities";
+ CREATE TRIGGER communities_search_vector_update
+ BEFORE INSERT OR UPDATE OF title, description ON "Communities"
+ FOR EACH ROW
+ EXECUTE FUNCTION community_search_vector_update();
+ `);
+};
+
+/**
+ * Backfill searchVector for all existing Pubs. Run once after adding the column.
+ * Uses batched updates to avoid locking the whole table.
+ */
+export const backfillPubSearchVectors = async () => {
+ // Step 1: Set title + description for ALL pubs that haven't been backfilled
+ await sequelize.query(`
+ UPDATE "Pubs" SET "searchVector" =
+ setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(description, '')), 'B')
+ WHERE "searchVector" IS NULL;
+ `);
+
+ // Step 2: Layer in byline (weight C) from PubAttributions
+ await sequelize.query(`
+ UPDATE "Pubs" p SET "searchVector" = p."searchVector" ||
+ setweight(to_tsvector('english', byline_sub.byline_text), 'C')
+ FROM (
+ SELECT pa."pubId",
+ string_agg(coalesce(u."fullName", pa.name), ' ') AS byline_text
+ FROM "PubAttributions" pa
+ LEFT JOIN "Users" u ON u.id = pa."userId"
+ WHERE pa."isAuthor" = true
+ AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL)
+ GROUP BY pa."pubId"
+ ) byline_sub
+ WHERE p.id = byline_sub."pubId";
+ `);
+
+ // Step 3: Layer in latest release doc content (weight D)
+ await sequelize.query(`
+ UPDATE "Pubs" p SET "searchVector" = p."searchVector" ||
+ setweight(to_tsvector('english', doc_sub.doc_text), 'D')
+ FROM (
+ SELECT DISTINCT ON (r."pubId") r."pubId",
+ extract_doc_text(d.content) AS doc_text
+ FROM "Releases" r
+ JOIN "Docs" d ON d.id = r."docId"
+ ORDER BY r."pubId", r."createdAt" DESC
+ ) doc_sub
+ WHERE p.id = doc_sub."pubId";
+ `);
+};
+
+/**
+ * Backfill searchVector for all existing Communities.
+ */
+export const backfillCommunitySearchVectors = async () => {
+ await sequelize.query(`
+ UPDATE "Communities" SET "searchVector" =
+ setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(description, '')), 'B')
+ WHERE "searchVector" IS NULL;
+ `);
+};
diff --git a/server/sequelize.ts b/server/sequelize.ts
index 005e1a024e..5385ee2585 100644
--- a/server/sequelize.ts
+++ b/server/sequelize.ts
@@ -120,5 +120,12 @@ export const knexInstance = knex({ client: 'pg' });
/* Change to true to update the model in the database. */
/* NOTE: This being set to true will erase your data. */
if (process.env.NODE_ENV !== 'test') {
- sequelize.sync({ force: false });
+ sequelize.sync({ force: false }).then(async () => {
+ // Install search triggers and backfill tsvector columns
+ const { installSearchTriggers, backfillPubSearchVectors, backfillCommunitySearchVectors } =
+ await import('server/search2/searchTriggers');
+ await installSearchTriggers();
+ await backfillPubSearchVectors();
+ await backfillCommunitySearchVectors();
+ });
}