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
8 changes: 8 additions & 0 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,11 @@ Zabbix monitoring:
1. Build — Containerfile builds successfully
2. Application health test — HTTP 200 from Node.js backend
3. Push — Image pushed to Quay.io

## Code Review Regression Prevention

Gemini Code Assist reviews every PR. To prevent later PRs from undoing reviewed fixes:

1. **Check before modifying**: When substantially modifying a file, check recent PRs for unresolved Gemini feedback on that file (`gh api repos/crunchtools/rotv/pulls/{N}/comments`). Address or preserve those fixes.
2. **Mark reviewed fixes**: When fixing a bug caught by code review, add an inline comment: `// Fix: <description> (PR #NNN review)`. This makes the fix visible to anyone refactoring the area later.
3. **Don't silently revert**: If a reviewed fix must be changed, explain why in the PR description.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Before making any changes, read these documents in order:
| SemVer | MAJOR.MINOR.PATCH versioning strictly followed |
| Documentation | Architecture docs for major features |
| AI Quality | Gourmand checks for AI slop detection |
| Review Fixes | Mark reviewed fixes with `// Fix: <desc> (PR #N review)` |

---

Expand Down
1 change: 1 addition & 0 deletions backend/migrations/012_add_research_context.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE pois ADD COLUMN IF NOT EXISTS research_context TEXT;
109 changes: 107 additions & 2 deletions backend/routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function createAdminRouter(pool) {
'name', 'poi_type', 'latitude', 'longitude', 'geometry', 'geometry_drive_file_id',
'property_owner', 'owner_id', 'brief_description', 'era_id', 'historical_description',
'primary_activities', 'surface', 'pets', 'cell_signal', 'more_info_link',
'events_url', 'news_url',
'events_url', 'news_url', 'research_context',
'length_miles', 'difficulty', 'boundary_type', 'boundary_color'
];
const updates = {};
Expand Down Expand Up @@ -226,7 +226,7 @@ export function createAdminRouter(pool) {
const allowedFields = [
'name', 'latitude', 'longitude', 'property_owner', 'owner_id', 'brief_description',
'era', 'era_id', 'historical_description', 'primary_activities', 'surface',
'pets', 'cell_signal', 'more_info_link', 'events_url', 'news_url', 'status_url'
'pets', 'cell_signal', 'more_info_link', 'events_url', 'news_url', 'research_context', 'status_url'
];
const updates = {};
const values = [];
Expand Down Expand Up @@ -611,6 +611,111 @@ export function createAdminRouter(pool) {
}
});

// Multi-pass research (Issue #102) — returns draft for approval
router.post('/ai/research-v2', isAdmin, async (req, res) => {
const { destination, adminContext } = req.body;

if (!destination || !destination.name) {
return res.status(400).json({ error: 'Destination with name is required' });
}

try {
// Inject admin context into the destination object
const destWithContext = { ...destination, research_context: adminContext || destination.research_context || '' };

// Fetch constrained lists
const activitiesResult = await pool.query('SELECT name FROM activities ORDER BY sort_order, name');
const availableActivities = activitiesResult.rows.map(row => row.name);

const erasResult = await pool.query('SELECT id, name FROM eras ORDER BY sort_order, name');
const availableEras = erasResult.rows.map(row => row.name);

const surfacesResult = await pool.query('SELECT name FROM surfaces ORDER BY sort_order, name');
const availableSurfaces = surfacesResult.rows.map(row => row.name);

const { researchLocationMultiPass } = await import('../services/geminiService.js');
const data = await researchLocationMultiPass(pool, destWithContext, availableActivities, availableEras, availableSurfaces);

console.log(`Admin ${req.user.email} researched (v2) location: ${destination.name}`);
res.json({ draft: true, data, destination_id: destination.id });
} catch (error) {
console.error('Error in multi-pass research:', error);
if (error.message?.includes('API key')) {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to research location. Please try again.' });
}
});

// Generate hero image for a POI (Issue #99)
router.post('/ai/generate-hero-image', isAdmin, async (req, res) => {
const { poiId, poiName, briefDescription, historicalDescription, era } = req.body;

if (!poiName) {
return res.status(400).json({ error: 'POI name is required' });
}

try {
const { generateHeroImage } = await import('../services/geminiService.js');
const result = await generateHeroImage(pool, {
name: poiName,
briefDescription,
historicalDescription,
era
});

console.log(`Admin ${req.user.email} generated hero image for: ${poiName}`);
res.json({
imageData: result.base64,
mimeType: result.mimeType,
promptUsed: result.promptUsed
});
} catch (error) {
console.error('Error generating hero image:', error);
res.status(500).json({ error: error.message || 'Failed to generate hero image.' });
}
});

// Accept and save a generated hero image
router.post('/ai/accept-hero-image', isAdmin, async (req, res) => {
const { poiId, imageData, mimeType } = req.body;

// Fix: sanitize poiId to numeric to prevent path traversal (PR #173 review)
const sanitizedPoiId = parseInt(poiId, 10);
if (!sanitizedPoiId || !imageData) {
return res.status(400).json({ error: 'Valid numeric POI ID and image data are required' });
}

try {
const imageBuffer = Buffer.from(imageData, 'base64');
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

The logic for determining the file extension based on the MIME type is incomplete. Gemini models can return images in image/webp format, which would currently default to a .png extension. This can lead to file type mismatches in storage or when serving the asset.

Suggested change
const imageBuffer = Buffer.from(imageData, 'base64');
const extension = mimeType === 'image/jpeg' ? 'jpg' : (mimeType === 'image/webp' ? 'webp' : 'png');

const mimeMap = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
const extension = mimeMap[mimeType] || 'png';
const filename = `hero-${sanitizedPoiId}-${Date.now()}.${extension}`;

// Fix: upload first, then delete old — avoids leaving POI imageless if upload fails (PR #173 review)
const uploadResult = await imageServerClient.uploadImage(
imageBuffer, sanitizedPoiId, 'primary', filename, mimeType
);

if (!uploadResult.success) {
throw new Error(uploadResult.error || 'Upload failed');
}

// Delete old primary asset after successful upload
const existingPrimary = await imageServerClient.getPrimaryAsset(sanitizedPoiId);
if (existingPrimary && existingPrimary.id !== uploadResult.assetId) {
await imageServerClient.deleteAsset(existingPrimary.id);
console.log(`[Hero Image] Deleted old primary asset ${existingPrimary.id} for POI ${sanitizedPoiId}`);
}

console.log(`Admin ${req.user.email} accepted hero image for POI ${sanitizedPoiId}: asset ${uploadResult.assetId}`);
res.json({ success: true, assetId: uploadResult.assetId });
} catch (error) {
console.error('Error accepting hero image:', error);
res.status(500).json({ error: error.message || 'Failed to save hero image.' });
}
});

// ============================================
// Activities Management Routes
// ============================================
Expand Down
32 changes: 19 additions & 13 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
scheduleImageBackup,
registerDatabaseBackupHandler,
scheduleDatabaseBackup,
stopJobScheduler
stopJobScheduler,
withJitter
} from './services/jobScheduler.js';
import { processItem, processPendingItems } from './services/moderationService.js';
import {
Expand Down Expand Up @@ -590,6 +591,11 @@ async function initDatabase() {
ALTER TABLE pois ADD COLUMN IF NOT EXISTS news_url TEXT
`);

// Add research_context column for multi-pass AI research
await client.query(`
ALTER TABLE pois ADD COLUMN IF NOT EXISTS research_context TEXT
`);

// Add status_url column for MTB Trail Status feature
await client.query(`
ALTER TABLE pois ADD COLUMN IF NOT EXISTS status_url VARCHAR(500)
Expand Down Expand Up @@ -1069,7 +1075,7 @@ app.get('/api/destinations', async (req, res) => {
p.owner_id, o.name as owner_name, p.property_owner,
p.brief_description, p.era_id, e.name as era_name, p.historical_description,
p.primary_activities, p.surface, p.pets, p.cell_signal, p.more_info_link,
p.image_drive_file_id, p.news_url, p.events_url, p.status_url,
p.image_drive_file_id, p.news_url, p.events_url, p.research_context, p.status_url,
p.deleted, p.created_at, p.updated_at
FROM pois p
LEFT JOIN pois o ON p.owner_id = o.id AND o.poi_type = 'virtual'
Expand All @@ -1093,7 +1099,7 @@ app.get('/api/destinations/:id', async (req, res) => {
p.owner_id, o.name as owner_name, p.property_owner,
p.brief_description, p.era_id, e.name as era_name, p.historical_description,
p.primary_activities, p.surface, p.pets, p.cell_signal, p.more_info_link,
p.image_drive_file_id, p.news_url, p.events_url, p.status_url,
p.image_drive_file_id, p.news_url, p.events_url, p.research_context, p.status_url,
p.deleted, p.created_at, p.updated_at
FROM pois p
LEFT JOIN pois o ON p.owner_id = o.id AND o.poi_type = 'virtual'
Expand Down Expand Up @@ -1881,15 +1887,15 @@ async function start() {
await initJobScheduler(connectionString);

// Register scheduled news collection handler (daily job for all POIs)
await registerNewsCollectionHandler(async () => {
await registerNewsCollectionHandler(withJitter(async () => {
console.log('Running scheduled news collection for all POIs...');
const newsCollectionResult = await runNewsCollection(pool, null);
if (newsCollectionResult.totalPois > 0) {
console.log(`News collection completed: ${newsCollectionResult.newsFound} news items, ${newsCollectionResult.eventsFound} events found`);
} else {
console.log('No POIs to collect');
}
});
}, 'news-collection'));

// Register batch news collection handler (for admin-triggered jobs via pg-boss)
await registerBatchNewsHandler(async (pgBossJobId, jobData) => {
Expand All @@ -1901,7 +1907,7 @@ async function start() {
await scheduleNewsCollection('0 6 * * *');

// Register scheduled trail status collection handler
await registerTrailStatusHandler(async () => {
await registerTrailStatusHandler(withJitter(async () => {
console.log('Running scheduled trail status collection for all MTB trails...');
const { runTrailStatusCollection } = await import('./services/trailStatusService.js');
const boss = app.get('boss');
Expand All @@ -1913,7 +1919,7 @@ async function start() {
} else {
console.log('No MTB trails to collect');
}
});
}, 'trail-status'));

// Register batch trail status collection handler
await registerBatchTrailStatusHandler(async (jobId, poiIds) => {
Expand All @@ -1931,7 +1937,7 @@ async function start() {
});

// Register moderation sweep handler (catches unprocessed items)
await registerModerationSweepHandler(async () => {
await registerModerationSweepHandler(withJitter(async () => {
await processPendingItems(pool);
// Retention: purge job logs older than 30 days
try {
Expand All @@ -1942,7 +1948,7 @@ async function start() {
} catch (err) {
console.error('[JobLogger] Retention cleanup failed:', err.message);
}
});
}, 'moderation-sweep'));

// Schedule moderation sweep every 15 minutes
await scheduleModerationSweep('*/15 * * * *');
Expand Down Expand Up @@ -1972,25 +1978,25 @@ async function start() {
return createDriveServiceWithRefresh(credentials, pool, adminResult.rows[0].id);
};

await registerImageBackupHandler(async () => {
await registerImageBackupHandler(withJitter(async () => {
console.log('Running scheduled image backup...');
const { triggerImageBackup } = await import('./services/backupService.js');
const drive = await getAdminDriveService();
const result = await triggerImageBackup(pool, drive);
console.log(`Image backup completed: ${result.uploaded} uploaded, ${result.skipped} skipped, ${result.failed} failed`);
});
}, 'image-backup'));

// Schedule nightly image backup at 2 AM Eastern
await scheduleImageBackup('0 2 * * *');

// Register database backup handler (nightly pg_dump to Drive)
await registerDatabaseBackupHandler(async () => {
await registerDatabaseBackupHandler(withJitter(async () => {
console.log('Running scheduled database backup...');
const { triggerBackup } = await import('./services/backupService.js');
const drive = await getAdminDriveService();
const result = await triggerBackup(pool, drive);
console.log(`Database backup completed: ${result.filename} (${result.driveFileId})`);
});
}, 'database-backup'));

// Schedule nightly database backup at 3 AM Eastern
await scheduleDatabaseBackup('0 3 * * *');
Expand Down
18 changes: 13 additions & 5 deletions backend/services/collection/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,24 @@ export const COLLECTION_TYPES = [
{
id: 'research',
label: 'POI Research',
description: 'AI research for POI metadata and icon generation',
description: 'Multi-pass AI research for POI metadata, descriptions, and hero images',
icon: '\u{1F50D}',
promptKeys: [],
promptKeys: [{
key: 'gemini_prompt_brief',
label: 'Brief Description Prompt',
placeholders: ['{{name}}', '{{era}}', '{{property_owner}}']
}, {
key: 'gemini_prompt_historical',
label: 'Historical Description Prompt',
placeholders: ['{{name}}', '{{era}}', '{{property_owner}}']
}],
scheduleJobName: null,
schedule: null,
statusTable: null,
historyTypes: ['research'],
triggerEndpoint: null,
manualTriggerMethod: null,
hasPrompt: false
triggerEndpoint: '/api/admin/ai/research-v2',
manualTriggerMethod: 'POST',
hasPrompt: true
},
{
id: 'cleanup',
Expand Down
Loading
Loading