fix(web): scope prerender CSS and finalize prod deploy updates

This commit is contained in:
2026-02-26 03:25:41 +00:00
parent 4e409d0f85
commit ff983619d9
13 changed files with 239 additions and 212 deletions

View File

@@ -11,6 +11,11 @@ POSTGRES_DB=pulsescore
# Server
PORT=8080
# Auth (required in production)
JWT_SECRET=CHANGEME_use_a_long_random_secret
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=168h
# CORS — comma-separated list of allowed origins
CORS_ALLOWED_ORIGINS=https://galdr.subcult.tv

View File

@@ -1,70 +1,70 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
push:
branches: [main]
pull_request:
branches: [main]
jobs:
go:
name: Go (lint, test, build)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: pulsescore
POSTGRES_PASSWORD: pulsescore
POSTGRES_DB: pulsescore_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U pulsescore -d pulsescore_test"
--health-interval 5s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
go:
name: Go (lint, test, build)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: pulsescore
POSTGRES_PASSWORD: pulsescore
POSTGRES_DB: pulsescore_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U pulsescore -d pulsescore_test"
--health-interval 5s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
- name: Test
env:
DATABASE_URL: postgres://pulsescore:pulsescore@localhost:5432/pulsescore_test?sslmode=disable
run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
- name: Test
env:
DATABASE_URL: postgres://pulsescore:pulsescore@localhost:5432/pulsescore_test?sslmode=disable
run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
- name: Build
run: go build -o bin/pulsescore-api ./cmd/api
- name: Build
run: go build -o bin/pulsescore-api ./cmd/api
react:
name: React (lint, build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
react:
name: React (lint, build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Build
run: npm run build

View File

@@ -1,69 +1,69 @@
name: Deploy Production
on:
workflow_dispatch:
inputs:
ref:
description: Git ref to deploy (branch, tag, or commit SHA)
required: true
default: main
run_migrations:
description: Request migrations during deploy (currently a no-op)
required: true
type: choice
default: "false"
options:
- "false"
- "true"
workflow_dispatch:
inputs:
ref:
description: Git ref to deploy (branch, tag, or commit SHA)
required: true
default: main
run_migrations:
description: Request migrations during deploy (currently a no-op)
required: true
type: choice
default: 'false'
options:
- 'false'
- 'true'
concurrency:
group: production-deploy
cancel-in-progress: false
group: production-deploy
cancel-in-progress: false
jobs:
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
environment: production
steps:
- name: Validate required secrets
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }}
run: |
set -euo pipefail
for key in VPS_HOST VPS_USER VPS_SSH_KEY VPS_APP_DIR; do
if [ -z "${!key}" ]; then
echo "::error::Missing required repository secret: $key"
exit 1
fi
done
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
environment: production
steps:
- name: Validate required secrets
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }}
run: |
set -euo pipefail
for key in VPS_HOST VPS_USER VPS_SSH_KEY VPS_APP_DIR; do
if [ -z "${!key}" ]; then
echo "::error::Missing required repository secret: $key"
exit 1
fi
done
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT != '' && secrets.VPS_PORT || 22 }}
script_stop: true
command_timeout: 45m
script: |
set -eu
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT != '' && secrets.VPS_PORT || 22 }}
script_stop: true
command_timeout: 45m
script: |
set -eu
cd "${{ secrets.VPS_APP_DIR }}"
cd "${{ secrets.VPS_APP_DIR }}"
git fetch --prune origin
git fetch --prune origin
TARGET_REF="${{ inputs.ref }}"
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET_REF}"; then
git checkout "$TARGET_REF"
git reset --hard "origin/${TARGET_REF}"
else
git checkout "$TARGET_REF"
fi
TARGET_REF="${{ inputs.ref }}"
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET_REF}"; then
git checkout "$TARGET_REF"
git reset --hard "origin/${TARGET_REF}"
else
git checkout "$TARGET_REF"
fi
chmod +x ./scripts/deploy/vps-deploy.sh
RUN_MIGRATIONS="${{ inputs.run_migrations }}" ./scripts/deploy/vps-deploy.sh
chmod +x ./scripts/deploy/vps-deploy.sh
RUN_MIGRATIONS="${{ inputs.run_migrations }}" ./scripts/deploy/vps-deploy.sh

View File

@@ -2,7 +2,7 @@
migrate-up migrate-down migrate-down-all migrate-create seed \
dev-db dev-db-down dev dev-stop \
web-install web-dev web-build web-lint web-format web-format-check web-preview \
docker-build docker-up docker-down docker-logs \
docker-build docker-up deploy-prod docker-down docker-logs \
check help
# ---------------------------------------------------------------------------
@@ -106,6 +106,9 @@ docker-build: ## Build production Docker images
docker-up: ## Start production stack
docker compose -f docker-compose.prod.yml up -d
deploy-prod: ## Deploy production stack (build + remove orphan containers)
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
docker-down: ## Stop production stack
docker compose -f docker-compose.prod.yml down

View File

@@ -31,7 +31,7 @@ pulse-score/
- **Backend:** Go (net/http)
- **Frontend:** React 19, TypeScript, Vite, TailwindCSS v4
- **Database:** PostgreSQL 18
- **Database:** PostgreSQL 16
- **Deployment:** Docker, Nginx, VPS
## Prerequisites
@@ -79,8 +79,8 @@ PulseScore ships to production through a manual GitHub Actions workflow:
- Workflow: `.github/workflows/deploy-prod.yml`
- Trigger: **Actions → Deploy Production → Run workflow**
- Inputs:
- `ref` (branch/tag/SHA to deploy)
- `run_migrations` (reserved; currently no-op)
- `ref` (branch/tag/SHA to deploy)
- `run_migrations` (reserved; currently no-op)
### Required GitHub repository secrets
@@ -140,12 +140,12 @@ PulseScore now includes a dedicated Stripe billing domain (separate from Stripe
- Plan catalog: `free`, `growth`, `scale` (`internal/billing/plans.go`)
- Protected billing APIs:
- `GET /api/v1/billing/subscription`
- `POST /api/v1/billing/checkout` (admin)
- `POST /api/v1/billing/portal-session` (admin)
- `POST /api/v1/billing/cancel` (admin)
- `GET /api/v1/billing/subscription`
- `POST /api/v1/billing/checkout` (admin)
- `POST /api/v1/billing/portal-session` (admin)
- `POST /api/v1/billing/cancel` (admin)
- Public billing webhook:
- `POST /api/v1/webhooks/stripe-billing`
- `POST /api/v1/webhooks/stripe-billing`
### Required production billing env vars

View File

@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:18-alpine
image: postgres:16-alpine
container_name: pulsescore-postgres
restart: unless-stopped
ports:

View File

@@ -1,58 +1,73 @@
services:
db:
image: postgres:18-alpine
container_name: pulsescore-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/init-db:/docker-entrypoint-initdb.d
networks:
- pulsescore-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
db:
image: postgres:16-alpine
container_name: pulsescore-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/init-db:/docker-entrypoint-initdb.d
networks:
- pulsescore-net
healthcheck:
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}',
]
interval: 5s
timeout: 5s
retries: 5
api:
build:
context: .
dockerfile: Dockerfile
container_name: pulsescore-api
restart: unless-stopped
environment:
PORT: "8080"
ENVIRONMENT: production
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
depends_on:
db:
condition: service_healthy
networks:
- pulsescore-net
- web
api:
build:
context: .
dockerfile: Dockerfile
container_name: pulsescore-api
restart: unless-stopped
environment:
PORT: '8080'
ENVIRONMENT: production
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
JWT_SECRET: ${JWT_SECRET}
JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-15m}
JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-168h}
STRIPE_BILLING_SECRET_KEY: ${STRIPE_BILLING_SECRET_KEY}
STRIPE_BILLING_PUBLISHABLE_KEY: ${STRIPE_BILLING_PUBLISHABLE_KEY}
STRIPE_BILLING_WEBHOOK_SECRET: ${STRIPE_BILLING_WEBHOOK_SECRET}
STRIPE_BILLING_PORTAL_RETURN_URL: ${STRIPE_BILLING_PORTAL_RETURN_URL}
STRIPE_BILLING_PRICE_GROWTH_MONTHLY: ${STRIPE_BILLING_PRICE_GROWTH_MONTHLY}
STRIPE_BILLING_PRICE_GROWTH_ANNUAL: ${STRIPE_BILLING_PRICE_GROWTH_ANNUAL}
STRIPE_BILLING_PRICE_SCALE_MONTHLY: ${STRIPE_BILLING_PRICE_SCALE_MONTHLY}
STRIPE_BILLING_PRICE_SCALE_ANNUAL: ${STRIPE_BILLING_PRICE_SCALE_ANNUAL}
depends_on:
db:
condition: service_healthy
networks:
- pulsescore-net
- web
web:
build:
context: ./web
dockerfile: Dockerfile
container_name: pulsescore-web
restart: unless-stopped
depends_on:
- api
networks:
- web
web:
build:
context: ./web
dockerfile: Dockerfile
container_name: pulsescore-web
restart: unless-stopped
depends_on:
- api
networks:
- web
volumes:
pgdata:
pgdata:
networks:
pulsescore-net:
driver: bridge
web:
external: true
pulsescore-net:
driver: bridge
web:
external: true

View File

@@ -257,28 +257,29 @@ function layoutHtml({
${cssTags.join("\n ")}
<style>
:root { color-scheme: light dark; }
body { margin: 0; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fff; color: #0f172a; }
.page { max-width: 1120px; margin: 0 auto; padding: 40px 24px; }
.muted { color: #475569; }
.chip { display: inline-block; margin-right: 8px; margin-bottom: 8px; border: 1px solid #cbd5e1; border-radius: 9999px; padding: 4px 12px; font-size: 12px; color: #334155; }
.hero { border: 1px solid #c7d2fe; border-radius: 16px; padding: 24px; background: #eef2ff; }
.grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(240px,1fr)); gap: 12px; margin-top: 20px; }
.card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; background: #fff; }
.card a { color: #1d4ed8; text-decoration: none; }
.card a:hover { text-decoration: underline; }
.cta { display: inline-block; margin-right: 10px; margin-top: 12px; background: #4f46e5; color: #fff; text-decoration: none; padding: 10px 16px; border-radius: 10px; font-weight: 600; }
.cta.secondary { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
body { margin: 0; }
.seo-prerender { font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fff; color: #0f172a; min-height: 100vh; }
.seo-prerender .page { max-width: 1120px; margin: 0 auto; padding: 40px 24px; }
.seo-prerender .muted { color: #475569; }
.seo-prerender .chip { display: inline-block; margin-right: 8px; margin-bottom: 8px; border: 1px solid #cbd5e1; border-radius: 9999px; padding: 4px 12px; font-size: 12px; color: #334155; }
.seo-prerender .hero { border: 1px solid #c7d2fe; border-radius: 16px; padding: 24px; background: #eef2ff; }
.seo-prerender .grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(240px,1fr)); gap: 12px; margin-top: 20px; }
.seo-prerender .card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; background: #fff; }
.seo-prerender .card a { color: #1d4ed8; text-decoration: none; }
.seo-prerender .card a:hover { text-decoration: underline; }
.seo-prerender .cta { display: inline-block; margin-right: 10px; margin-top: 12px; background: #4f46e5; color: #fff; text-decoration: none; padding: 10px 16px; border-radius: 10px; font-weight: 600; }
.seo-prerender .cta.secondary { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
@media (prefers-color-scheme: dark) {
body { background: #020617; color: #e2e8f0; }
.hero { background: #1e1b4b; border-color: #4338ca; }
.card { background: #0f172a; border-color: #1e293b; }
.muted, .chip { color: #cbd5e1; border-color: #334155; }
.card a, .cta.secondary { color: #c7d2fe; }
.seo-prerender { background: #020617; color: #e2e8f0; }
.seo-prerender .hero { background: #1e1b4b; border-color: #4338ca; }
.seo-prerender .card { background: #0f172a; border-color: #1e293b; }
.seo-prerender .muted, .seo-prerender .chip { color: #cbd5e1; border-color: #334155; }
.seo-prerender .card a, .seo-prerender .cta.secondary { color: #c7d2fe; }
}
</style>
</head>
<body>
<div id="root">${body}</div>
<div id="root"><div class="seo-prerender">${body}</div></div>
${scriptTags.join("\n ")}
</body>
</html>

View File

@@ -174,8 +174,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
applySession(data);
// Retry original request with new token
originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization =
`Bearer ${data.tokens.access_token}`;
originalRequest.headers.Authorization = `Bearer ${data.tokens.access_token}`;
return api.request(originalRequest);
} catch {
clearSession();

View File

@@ -57,15 +57,18 @@ export function ToastProvider({ children }: { children: ReactNode }) {
[removeToast],
);
const success = useCallback((msg: string) => addToast("success", msg), [
addToast,
]);
const error = useCallback((msg: string) => addToast("error", msg), [
addToast,
]);
const warning = useCallback((msg: string) => addToast("warning", msg), [
addToast,
]);
const success = useCallback(
(msg: string) => addToast("success", msg),
[addToast],
);
const error = useCallback(
(msg: string) => addToast("error", msg),
[addToast],
);
const warning = useCallback(
(msg: string) => addToast("warning", msg),
[addToast],
);
const info = useCallback((msg: string) => addToast("info", msg), [addToast]);
const value = useMemo<ToastContextValue>(

View File

@@ -48,7 +48,9 @@ function normalizeBuckets(rawBuckets: unknown): ScoreDistributionBucket[] {
});
}
export async function getScoreDistributionCached(options?: { force?: boolean }) {
export async function getScoreDistributionCached(options?: {
force?: boolean;
}) {
const force = options?.force === true;
if (force) {
@@ -57,11 +59,7 @@ export async function getScoreDistributionCached(options?: { force?: boolean })
}
const now = Date.now();
if (
!force &&
cachedBuckets &&
now - lastFetchedAtMs < CACHE_TTL_MS
) {
if (!force && cachedBuckets && now - lastFetchedAtMs < CACHE_TTL_MS) {
return cachedBuckets;
}

View File

@@ -14,9 +14,10 @@ export default function NotificationsTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const toast = useToast();
const mutedSet = useMemo(() => new Set(prefs?.muted_rule_ids ?? []), [
prefs?.muted_rule_ids,
]);
const mutedSet = useMemo(
() => new Set(prefs?.muted_rule_ids ?? []),
[prefs?.muted_rule_ids],
);
useEffect(() => {
async function load() {

View File

@@ -14,7 +14,9 @@ const ScoringTab = lazy(() => import("@/pages/settings/ScoringTab"));
const BillingTab = lazy(() => import("@/pages/settings/BillingTab"));
const TeamTab = lazy(() => import("@/pages/settings/TeamTab"));
const AlertsTab = lazy(() => import("@/pages/settings/AlertsTab"));
const NotificationsTab = lazy(() => import("@/pages/settings/NotificationsTab"));
const NotificationsTab = lazy(
() => import("@/pages/settings/NotificationsTab"),
);
const StripeCallbackPage = lazy(
() => import("@/pages/settings/StripeCallbackPage"),
);