Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3f3f291
feat: add Serper service with PostGIS geographic grounding (#196)
fatherlinux Apr 7, 2026
bade7c2
feat: integrate Serper Layer 2 into news collection pipeline
fatherlinux Apr 7, 2026
3b935d8
feat: add Serper API key management UI to Data Collection settings
fatherlinux Apr 7, 2026
8b397ad
docs: add comprehensive Serper integration documentation and testing …
fatherlinux Apr 7, 2026
5e3c681
feat: install PostGIS and enable geographic grounding for Serper inte…
fatherlinux Apr 7, 2026
c333def
feat: improve UI readability and persist auth bypass for testing
fatherlinux Apr 9, 2026
af9b04c
fix: move auth bypass from container to environment file
fatherlinux Apr 9, 2026
3521be0
fix: add missing showImage parameter to EditView function
fatherlinux Apr 9, 2026
dbfa6bd
fix: resolve CI failures - inline single-use helper and skip PostGIS …
fatherlinux Apr 9, 2026
2728081
fix: resolve Gourmand violations and skip PostGIS entirely
fatherlinux Apr 9, 2026
4750b73
fix: remove remaining comments and restore PostGIS with --skip-broken
fatherlinux Apr 9, 2026
fd4490b
fix: remove getGeographicContext tests after function inlining
fatherlinux Apr 9, 2026
d0894dc
fix: revert Containerfile to match master, remove last test comment
fatherlinux Apr 9, 2026
7ca6b9c
fix: workaround RHEL 10 PostGIS dependency regression
fatherlinux Apr 9, 2026
8218115
fix: remove stray semicolons from test file (syntax error)
fatherlinux Apr 9, 2026
45dae3b
fix: properly mock node-fetch in serperService tests
fatherlinux Apr 9, 2026
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
10 changes: 7 additions & 3 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ RUN dnf install -y nodejs npm \
# Install Playwright globally with Chromium (pinned to match backend/package.json)
RUN npm install -g playwright@1.58.1 && npx playwright install chromium

# Add PostgreSQL 17 from official pgdg repository (no RHSM needed)
RUN dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-x86_64/pgdg-redhat-repo-latest.noarch.rpm && \
dnf install -y postgresql17-server postgresql17 && \
# Add PostgreSQL 17 + PostGIS from official pgdg repository (no RHSM needed)
# EPEL provides PostGIS dependencies (hdf5, xerces-c)
# WORKAROUND: PostGIS fails on RHEL 10 due to missing libboost_serialization.so.1.83.0 (as of 2026-04-09)
# Allow build to continue without PostGIS until RHEL 10 repos are fixed
RUN dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm && \
dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-x86_64/pgdg-redhat-repo-latest.noarch.rpm && \
(dnf install -y postgresql17-server postgresql17 postgis35_17 || dnf install -y postgresql17-server postgresql17) && \
dnf clean all

# Create symlinks for PostgreSQL commands
Expand Down
38 changes: 38 additions & 0 deletions backend/migrations/018_add_postgis_support.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Migration 018: Add PostGIS support for geographic grounding
-- Required for Serper integration spatial queries

-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;

-- Add PostGIS geometry column to pois table
-- This will store point locations for spatial queries
ALTER TABLE pois ADD COLUMN IF NOT EXISTS geom geometry(Point, 4326);

-- Populate geometry column from existing latitude/longitude
-- SRID 4326 = WGS 84 (standard GPS coordinates)
UPDATE pois
SET geom = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND geom IS NULL;

-- Create spatial index for fast geographic queries
-- Used by getGeographicContext() in serperService.js
CREATE INDEX IF NOT EXISTS idx_pois_geom ON pois USING GIST (geom);

-- Add geometry column for boundary polygons
-- This will store polygon data from the existing JSONB geometry field
ALTER TABLE pois ADD COLUMN IF NOT EXISTS boundary_geom geometry(Polygon, 4326);

-- Note: Boundary polygon migration from JSONB will be handled separately
-- The JSONB geometry field contains GeoJSON that needs custom parsing
-- For now, boundaries can be re-imported from GeoJSON files

-- Verify PostGIS is working
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') THEN
RAISE EXCEPTION 'PostGIS extension not available';
END IF;
RAISE NOTICE 'PostGIS extension installed successfully';
END $$;
45 changes: 45 additions & 0 deletions backend/migrations/019_migrate_boundary_geometry.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- Migration 019: Migrate boundary polygons from JSONB to PostGIS geometry
-- This converts the existing GeoJSON data to proper PostGIS geometry
-- Handles both Polygon and MultiPolygon geometries

-- First, change column type to accept both Polygon and MultiPolygon
ALTER TABLE pois DROP COLUMN IF EXISTS boundary_geom;
ALTER TABLE pois ADD COLUMN boundary_geom geometry(MultiPolygon, 4326);

-- Convert JSONB GeoJSON to PostGIS geometry for boundaries
-- Ensures all geometries are MultiPolygon (converts Polygon → MultiPolygon if needed)
UPDATE pois
SET boundary_geom = ST_SetSRID(
ST_Multi(ST_GeomFromGeoJSON(geometry::text))::geometry(MultiPolygon, 4326),
4326
)
WHERE poi_type = 'boundary'
AND geometry IS NOT NULL
AND boundary_geom IS NULL;

-- Verify all boundaries have PostGIS geometry
DO $$
DECLARE
boundary_count INTEGER;
migrated_count INTEGER;
BEGIN
SELECT COUNT(*) INTO boundary_count
FROM pois
WHERE poi_type = 'boundary';

SELECT COUNT(*) INTO migrated_count
FROM pois
WHERE poi_type = 'boundary'
AND boundary_geom IS NOT NULL;

RAISE NOTICE 'Boundary migration: % of % boundaries have PostGIS geometry',
migrated_count, boundary_count;

IF migrated_count < boundary_count THEN
RAISE WARNING 'Some boundaries missing PostGIS geometry - check GeoJSON format';
END IF;
END $$;

-- Create spatial index for boundary polygons (if not exists)
CREATE INDEX IF NOT EXISTS idx_pois_boundary_geom ON pois USING GIST (boundary_geom)
WHERE poi_type = 'boundary';
35 changes: 35 additions & 0 deletions backend/routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ export function createAdminRouter(pool, invalidateMosaicCache) {

const allowedKeys = [
'gemini_api_key',
'serper_api_key',
'gemini_prompt_brief',
'gemini_prompt_historical',
'ai_search_primary',
Expand Down Expand Up @@ -512,6 +513,40 @@ export function createAdminRouter(pool, invalidateMosaicCache) {
}
});

// Test Serper API key
router.post('/settings/serper-api-key/test', isAdmin, async (req, res) => {
try {
const { testSerperApiKey } = await import('../services/serperService.js');
const isValid = await testSerperApiKey(pool);

if (isValid) {
res.json({ success: true, message: 'Serper API key is valid' });
} else {
res.json({ success: false, message: 'Serper API key is invalid or not configured' });
}
} catch (error) {
console.error('Error testing Serper API key:', error);
res.status(500).json({ success: false, message: 'Failed to test API key', error: error.message });
}
});

// Test Apify API token
router.post('/settings/apify-api-token/test', isAdmin, async (req, res) => {
try {
const { testApifyToken } = await import('../services/apifyService.js');
const isValid = await testApifyToken(pool);

if (isValid) {
res.json({ success: true, message: 'Apify API token is valid' });
} else {
res.json({ success: false, message: 'Apify API token is invalid or not configured' });
}
} catch (error) {
console.error('Error testing Apify API token:', error);
res.status(500).json({ success: false, message: 'Failed to test API token', error: error.message });
}
});

// ============================================
// AI Content Generation Routes (Gemini)
// ============================================
Expand Down
23 changes: 23 additions & 0 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ if (process.env.FACEBOOK_APP_ID && process.env.FACEBOOK_APP_SECRET) {

// Get current user
router.get('/user', (req, res) => {
// Test bypass for local development
if (process.env.NODE_ENV === 'test' && process.env.BYPASS_AUTH === 'true') {
return res.json({
id: 999,
email: 'test-admin@rotv.local',
name: 'Test Admin',
pictureUrl: null,
isAdmin: true,
role: 'admin',
favorites: [],
preferences: {}
});
}

if (req.isAuthenticated()) {
// Return user info without sensitive data (no oauth_credentials)
const { id, email, name, picture_url, is_admin, role, favorite_destinations, preferences } = req.user;
Expand Down Expand Up @@ -132,6 +146,15 @@ router.post('/logout', (req, res) => {

// Check auth status (lightweight)
router.get('/status', (req, res) => {
// Test bypass for local development
if (process.env.NODE_ENV === 'test' && process.env.BYPASS_AUTH === 'true') {
return res.json({
authenticated: true,
isAdmin: true,
role: 'admin'
});
}

res.json({
authenticated: req.isAuthenticated(),
isAdmin: req.user?.is_admin || false,
Expand Down
4 changes: 2 additions & 2 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const pool = new Pool({
host: process.env.PGHOST || 'localhost',
port: process.env.PGPORT || 5432,
database: process.env.PGDATABASE || 'rotv',
user: process.env.PGUSER || 'rotv',
user: process.env.PGUSER || 'postgres', // Use standard PostgreSQL superuser
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Defaulting to the postgres superuser for application database connections is a significant security risk. It violates the principle of least privilege by granting the application full control over the entire database instance. It is highly recommended to use a dedicated application user with permissions restricted to the specific database.

Suggested change
user: process.env.PGUSER || 'postgres', // Use standard PostgreSQL superuser
user: process.env.PGUSER || 'rotv',

password: process.env.PGPASSWORD || 'rotv',
// Background jobs use up to 10 concurrent connections
// Reserve extra for API requests to prevent blocking
Expand Down Expand Up @@ -2612,7 +2612,7 @@ async function start() {
startMcpServer(pool, app.get('boss'), parseInt(process.env.MCP_PORT || '3001'));
}

app.listen(PORT, '::', () => {
app.listen(PORT, '0.0.0.0', () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Changing the listening interface from :: to 0.0.0.0 restricts the application to IPv4 only. Using :: is generally preferred as it allows the application to handle both IPv4 and IPv6 connections (dual-stack), which is important for modern networking environments and container orchestration platforms.

Suggested change
app.listen(PORT, '0.0.0.0', () => {
app.listen(PORT, '::', () => {

console.log(`Roots of The Valley API running on port ${PORT}`);
});
}
Expand Down
27 changes: 27 additions & 0 deletions backend/services/apifyService.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,30 @@ export async function fetchFacebookPosts(pool, statusUrl, maxItems = 10) {
export function isFacebookUrl(url) {
return url.includes('facebook.com');
}

/**
* Test Apify API token validity
* Makes a simple API call to verify the token works
* @param {Pool} pool - Database connection pool
* @returns {Promise<boolean>} - True if token is valid
*/
export async function testApifyToken(pool) {
const token = await getApifyToken(pool);
if (!token) {
return false;
}

try {
// Test with a simple actor list call
const url = `${APIFY_BASE_URL}/acts?token=${token}&limit=1`;
const response = await fetch(url, {
method: 'GET',
signal: AbortSignal.timeout(10000) // 10 second timeout
});

return response.ok;
} catch (err) {
console.error('[Apify] API token test failed:', err.message);
return false;
}
}
Loading
Loading