Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ on:
branches: [ "main" ]
paths:
- "mobile/**"
# iOS-only changes shouldn't cut an Android release.
- "!mobile/ios/**"
- ".github/workflows/android-build.yml"
tags:
- "v*.*.*-android"
Expand Down Expand Up @@ -103,9 +105,12 @@ jobs:
- name: Install root dependencies
run: pnpm install --frozen-lockfile

# --ignore-workspace: the repo-root pnpm-workspace.yaml otherwise makes
# this install the root project and leaves mobile/node_modules empty,
# which fails the `npx cap sync` step below.
- name: Install mobile dependencies
working-directory: mobile
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --ignore-workspace

- name: Write google-services.json
env:
Expand Down
92 changes: 92 additions & 0 deletions .github/workflows/ios-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: iOS Build

# Compile check for the Capacitor iOS shell: builds the App target for the
# iOS Simulator without code signing, so changes under mobile/ surface Swift,
# Xcode project, and Swift Package resolution breakage without anyone opening
# Xcode. There is no release lane yet; TestFlight uploads need an Apple
# Developer team plus signing secrets. See mobile/README.md.

on:
pull_request:
paths:
- "mobile/**"
- "!mobile/android/**"
- ".github/workflows/ios-build.yml"
push:
branches: [ "main" ]
paths:
- "mobile/**"
- "!mobile/android/**"
- ".github/workflows/ios-build.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: Build for iOS Simulator
runs-on: macos-latest
timeout-minutes: 45

steps:
- name: Checkout Code
uses: actions/checkout@v4

# Version comes from the "packageManager" field in package.json.
- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: mobile/pnpm-lock.yaml

# --ignore-workspace: the repo-root pnpm-workspace.yaml otherwise makes
# this install the root project instead of mobile/.
- name: Install mobile dependencies
working-directory: mobile
run: pnpm install --frozen-lockfile --ignore-workspace

- name: Write GoogleService-Info.plist (if provided)
env:
GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST_BASE64 }}
run: |
set -euo pipefail

if [[ -n "${GOOGLE_SERVICE_INFO_PLIST_BASE64:-}" ]]; then
echo "Using GOOGLE_SERVICE_INFO_PLIST_BASE64 secret"
echo "${GOOGLE_SERVICE_INFO_PLIST_BASE64}" | base64 --decode > mobile/ios/App/App/GoogleService-Info.plist
else
echo "Secret not set; building with push registration disabled"
fi

- name: Sync Capacitor to iOS
working-directory: mobile
run: npx cap sync ios

- name: Cache Swift packages
uses: actions/cache@v4
with:
path: mobile/ios/App/build/SourcePackages
key: spm-${{ runner.os }}-${{ hashFiles('mobile/ios/App/CapApp-SPM/Package.swift', 'mobile/ios/App/App.xcodeproj/project.pbxproj', 'mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-

- name: Build (iOS Simulator, unsigned)
working-directory: mobile/ios/App
run: |
set -euo pipefail

xcodebuild -version
xcodebuild \
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
CODE_SIGNING_ALLOWED=NO \
build
12 changes: 6 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document is the canonical project + operations reference for Claude Code in

## Project Overview

Polymer is The Polytechnic's web platform (public newspaper site + Payload CMS admin) built on Next.js + Payload + PostgreSQL, with a Capacitor Android shell that wraps the production site and receives FCM breaking-news pushes.
Polymer is The Polytechnic's web platform (public newspaper site + Payload CMS admin) built on Next.js + Payload + PostgreSQL, with Capacitor Android and iOS shells that wrap the production site and receive FCM breaking-news pushes.

**This project is live in production with a real production database. Exercise caution with schema changes, migrations, and any destructive operations.**

Expand All @@ -30,10 +30,10 @@ For deeper architectural context, see [`docs/`](docs/) (architecture, data model
- `components/`: UI + article layout + dashboard components
- `lib/`: server helpers (PostHog, FCM, theme, archive query, weather, homepage slot resolution)
- `migrations/`: Payload-format TypeScript migrations registered via `migrations/index.ts`
- `mobile/`: Capacitor Android shell (separate `package.json`)
- `mobile/`: Capacitor Android + iOS shells (separate `package.json`; install with `pnpm install --ignore-workspace`)
- `scripts/`: deploy/runtime scripts (`run_deploy_sql_migrations.sh`, `deploy-smoke.mjs`, `generate-env.js`)
- `middleware.ts`: returns `410 Gone` for matching article URLs whose row is unpublished
- `.github/workflows/`: CI, production deploy, and Android release workflows
- `.github/workflows/`: CI, production deploy, Android release, and iOS simulator build workflows

## Core Behavior

Expand All @@ -60,7 +60,7 @@ Collections:
- `submissions`: public op-ed / letter submissions (anonymous create, staff triage)
- `event-submissions`: public event submissions for the calendar
- `logos`: branded section logos and homepage assets
- `device-tokens`: registered Android FCM tokens (anonymous create via `/api/push/register`, admin-only read/delete)
- `device-tokens`: registered Android and iOS FCM tokens (anonymous create via `/api/push/register`, admin-only read/delete)

Globals:

Expand Down Expand Up @@ -211,9 +211,9 @@ Mixing PM2 users creates split daemons/process lists and inconsistent runtime ow

## Push Notifications (Breaking News)

- registration: `POST /api/push/register` (Android client; in-memory rate limit + token de-dupe)
- registration: `POST /api/push/register` (Android + iOS clients; in-memory rate limit + token de-dupe)
- fan-out: `POST /api/push/send` (internal; requires `x-internal-secret` matching `INTERNAL_PUSH_SECRET`)
- transport: FCM HTTP v1 via `lib/fcm.ts` using `FCM_SERVICE_ACCOUNT_JSON`
- transport: FCM HTTP v1 via `lib/fcm.ts` using `FCM_SERVICE_ACCOUNT_JSON`; iOS devices register FCM tokens too (APNs → FCM swap in the app), so there is no separate APNs sender
- trigger: `Articles.afterChange` when an article transitions to published with `breakingNews=true`
- if `INTERNAL_PUSH_SECRET` or `FCM_SERVICE_ACCOUNT_JSON` is unset, the fan-out becomes a no-op so dev/CI is unaffected

Expand Down
6 changes: 5 additions & 1 deletion app/(frontend)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import configPromise from "@/payload.config";
import { User } from "@/payload-types";
import ThemeStyle from "@/components/ThemeStyle";
import BottomNav from "@/components/BottomNav";
import SearchOverlayHost from "@/components/SearchOverlayHost";
import { getTheme } from "@/lib/getTheme";
import { getSeo } from "@/lib/getSeo";

Expand Down Expand Up @@ -179,7 +180,10 @@ export default async function RootLayout({
<ThemeProvider initialDarkMode={isDarkMode} logoSrcs={siteTheme.logoSrcs}>
<SiteAnalytics user={analyticsUser} />
<WebVitals />
<HeaderTransitionProvider>{children}</HeaderTransitionProvider>
<HeaderTransitionProvider>
{children}
<SearchOverlayHost />
</HeaderTransitionProvider>
<BottomNav />
</ThemeProvider>
</body>
Expand Down
23 changes: 6 additions & 17 deletions app/(frontend)/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import React from 'react';
import type { Metadata } from 'next';
import Header from '@/components/Header';
import SearchInput from '@/components/SearchInput';
import { sanitizeSearchQuery } from '@/utils/search';
import SearchOverlay from '@/components/SearchOverlay';
import { getSeo } from '@/lib/getSeo';

export async function generateMetadata(): Promise<Metadata> {
Expand All @@ -16,20 +13,12 @@ export async function generateMetadata(): Promise<Metadata> {
}
}

type Args = {
searchParams: Promise<{ q?: string }>;
};

export default async function SearchPage({ searchParams }: Args) {
const { q } = await searchParams;
const query = sanitizeSearchQuery(q);

// The search overlay on a solid background. Reads ?q= itself so it can pick up
// the overlay's results and scroll position (see SearchOverlay's handoff).
export default function SearchPage() {
return (
<main className="min-h-screen bg-bg-main transition-colors duration-300">
<Header compact />
<div className="mx-auto max-w-[1280px] px-4 md:px-6 pt-20 pb-16">
<SearchInput defaultValue={query} />
</div>
<main>
<SearchOverlay variant="page" />
</main>
);
}
130 changes: 85 additions & 45 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
import { Article } from "@/components/FrontPage/types";
import {
DEFAULT_SEARCH_PAGE_SIZE,
parseSearchFilters,
parseSearchPage,
parseSearchPageSize,
sanitizeSearchQuery,
searchRangeStart,
type SearchFilters,
} from "@/utils/search";
import { checkRateLimit } from "@/utils/rateLimit";

const SEARCH_RATE_LIMIT = 40;
const SEARCH_RATE_LIMIT = 80; // each overlay search sends two requests (headline + full)
const SEARCH_RATE_LIMIT_WINDOW_MS = 10_000;

// Generate alternate separator forms of the query so "anti-discrimination",
Expand All @@ -28,7 +31,7 @@
return [...forms].filter(f => f.trim().length > 0);
}

type PayloadSearchArticle = {

Check warning on line 34 in app/api/search/route.ts

View workflow job for this annotation

GitHub Actions / validate

'PayloadSearchArticle' is defined but never used
id: number;
title: string;
slug?: string | null;
Expand All @@ -42,58 +45,92 @@
_status?: string | null;
};

async function searchPayload(queryFormsLower: string[], page: number, pageSize: number) {
const payload = await getPayload({ config });
const articleSearchSelect = {
title: true,
plainTitle: true,
slug: true,
subdeck: true,
featuredImage: true,
section: true,
kicker: true,
publishedDate: true,
createdAt: true,
authors: true,
writeInAuthors: true,
isFollytechnic: true,
} as const;

// Build OR conditions: match any query form in plainTitle, subdeck, or kicker
// (title is a richText field; plainTitle is the auto-derived plain-text version used for search)
const orConditions: Where[] = [];
for (const form of queryFormsLower) {
orConditions.push({ plainTitle: { like: form } });
orConditions.push({ subdeck: { like: form } });
orConditions.push({ kicker: { like: form } });
orConditions.push({ 'writeInAuthors.name': { like: form } });
orConditions.push({ plainContent: { like: form } });
}
const articleSearchSelect = {
title: true,
plainTitle: true,
slug: true,
subdeck: true,
featuredImage: true,
section: true,
kicker: true,
publishedDate: true,
createdAt: true,
authors: true,
writeInAuthors: true,
isFollytechnic: true,
} as const;

// Short fields scan fast, so these matches come back first (title is a richText
// field; plainTitle is the auto-derived plain-text version used for search).
const HEADLINE_FIELDS = ["plainTitle", "subdeck", "kicker", "writeInAuthors.name"];
const BODY_FIELDS = ["plainContent"];

function matchAny(fields: string[], queryFormsLower: string[]): Where {
return { or: queryFormsLower.flatMap((form) => fields.map((field) => ({ [field]: { like: form } }))) };
}

type Payload = Awaited<ReturnType<typeof getPayload>>;

async function matchingIds(payload: Payload, where: Where, sort: string): Promise<number[]> {
const result = await payload.find({
collection: "articles",
where: {
and: [
{ _status: { equals: "published" } },
{ or: orConditions },
],
},
sort: "-publishedDate",
limit: pageSize,
page,
where,
sort,
pagination: false,
depth: 0,
select: { publishedDate: true },
});
return result.docs.map((doc) => doc.id);
}

async function articlesByIds(payload: Payload, ids: number[]): Promise<Article[]> {
if (ids.length === 0) return [];
const result = await payload.find({
collection: "articles",
where: { id: { in: ids } },
pagination: false,
depth: 1,
select: articleSearchSelect,
});
const docsById = new Map(result.docs.map((doc) => [doc.id, doc]));
return ids
.map((id) => docsById.get(id))
.map((doc) => doc && formatArticle(doc as unknown as Parameters<typeof formatArticle>[0], { absoluteDate: true }))
.filter((a): a is Article => !!a);
}

// Headline matches come first, then articles that only mention the query in the
// body, each newest (or oldest) first. `headlineOnly` skips the slow body scan so
// the client can show the first results while the full search finishes.
async function searchPayload(
queryFormsLower: string[],
page: number,
pageSize: number,
filters: SearchFilters,
headlineOnly: boolean,
) {
const payload = await getPayload({ config });
const base: Where[] = [{ _status: { equals: "published" } }];
if (filters.section) base.push({ section: { equals: filters.section } });
const since = searchRangeStart(filters.range);
if (since) base.push({ publishedDate: { greater_than_equal: since.toISOString() } });
const sort = filters.sort === "oldest" ? "publishedDate" : "-publishedDate";

const articles = result.docs
.map((doc) => formatArticle(doc as unknown as Parameters<typeof formatArticle>[0], { absoluteDate: true }))
.filter((a): a is Article => a !== null);
const headlineWhere: Where = { and: [...base, matchAny(HEADLINE_FIELDS, queryFormsLower)] };
const bodyWhere: Where = { and: [...base, matchAny(BODY_FIELDS, queryFormsLower)] };
const [headlineIds, bodyIds] = await Promise.all([
matchingIds(payload, headlineWhere, sort),
headlineOnly ? Promise.resolve([]) : matchingIds(payload, bodyWhere, sort),
]);
const headlineSet = new Set(headlineIds);
const ids = [...headlineIds, ...bodyIds.filter((id) => !headlineSet.has(id))];

const offset = (page - 1) * pageSize;
return {
articles,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
page: result.page ?? page,
articles: await articlesByIds(payload, ids.slice(offset, offset + pageSize)),
totalDocs: ids.length,
totalPages: Math.ceil(ids.length / pageSize),
page,
};
}

Expand All @@ -120,6 +157,8 @@
const q = sanitizeSearchQuery(request.nextUrl.searchParams.get("q"));
const page = parseSearchPage(request.nextUrl.searchParams.get("page"));
const pageSize = parseSearchPageSize(request.nextUrl.searchParams.get("pageSize"));
const filters = parseSearchFilters(request.nextUrl.searchParams);
const headlineOnly = request.nextUrl.searchParams.get("part") === "headline";

if (!q) {
return Response.json({
Expand All @@ -136,7 +175,7 @@
const queryFormsLower = forms.map((form) => form.toLowerCase()).filter((form) => form.length > 0);

try {
const result = await searchPayload(queryFormsLower, page, pageSize);
const result = await searchPayload(queryFormsLower, page, pageSize, filters, headlineOnly);

return Response.json({
articles: result.articles,
Expand All @@ -145,6 +184,7 @@
query: q,
totalPages: result.totalPages,
totalResults: result.totalDocs,
partial: headlineOnly,
});
} catch {
return Response.json({
Expand Down
Loading
Loading