diff --git a/SOLR_LIBRARY_MIGRATION.md b/SOLR_LIBRARY_MIGRATION.md new file mode 100644 index 0000000..13ca226 --- /dev/null +++ b/SOLR_LIBRARY_MIGRATION.md @@ -0,0 +1,196 @@ +# Solr Library Separation Migration Guide + +This guide explains how to migrate existing StoryCove deployments to support proper library separation in Solr search. + +## What Changed + +The Solr service has been enhanced to support multi-tenant library separation by: +- Adding a `libraryId` field to all Solr documents +- Filtering all search queries by the current library context +- Ensuring complete data isolation between libraries + +## Migration Options + +### Option 1: Docker Volume Reset (Recommended for Docker) + +**Best for**: Development, staging, and Docker-based deployments where data loss is acceptable. + +```bash +# Stop the application +docker-compose down + +# Remove only the Solr data volume (preserves database and images) +docker volume rm storycove_solr_data + +# Restart - Solr will recreate cores with new schema +docker-compose up -d + +# Wait for services to start, then trigger reindex via admin panel +``` + +**Pros**: Clean, simple, guaranteed to work +**Cons**: Requires downtime, loses existing search index + +### Option 2: Schema API Migration (Production Safe) + +**Best for**: Production environments where you need to preserve uptime. + +```bash +# Add libraryId field to stories core +curl -X POST "http://your-solr-host:8983/solr/storycove_stories/schema" \ + -H "Content-Type: application/json" \ + -d '{ + "add-field": { + "name": "libraryId", + "type": "string", + "indexed": true, + "stored": true, + "required": false + } + }' + +# Add libraryId field to authors core +curl -X POST "http://your-solr-host:8983/solr/storycove_authors/schema" \ + -H "Content-Type: application/json" \ + -d '{ + "add-field": { + "name": "libraryId", + "type": "string", + "indexed": true, + "stored": true, + "required": false + } + }' + +# Then use the admin migration endpoint +curl -X POST "http://your-app-host/api/admin/search/solr/migrate-library-schema" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +**Pros**: No downtime, preserves service availability +**Cons**: More complex, requires API access + +### Option 3: Application-Level Migration (Recommended for Production) + +**Best for**: Production environments with proper admin access. + +1. **Deploy the code changes** to your environment +2. **Access the admin panel** of your application +3. **Navigate to search settings** +4. **Use the "Migrate Library Schema" button** or API endpoint: + ``` + POST /api/admin/search/solr/migrate-library-schema + ``` + +**Pros**: User-friendly, handles all complexity internally +**Cons**: Requires admin access to application + +## Step-by-Step Migration Process + +### For Docker Deployments + +1. **Backup your data** (optional but recommended): + ```bash + # Backup database + docker-compose exec postgres pg_dump -U storycove storycove > backup.sql + ``` + +2. **Pull the latest code** with library separation fixes + +3. **Choose migration approach**: + - **Quick & Clean**: Use Option 1 (volume reset) + - **Production**: Use Option 2 or 3 + +4. **Verify migration**: + - Log in with different library passwords + - Perform searches to confirm isolation + - Check that new content gets indexed with library IDs + +### For Kubernetes/Production Deployments + +1. **Update your deployment** with the new container images + +2. **Add the libraryId field** to Solr schema using Option 2 + +3. **Use the migration endpoint** (Option 3): + ```bash + kubectl exec -it deployment/storycove-backend -- \ + curl -X POST http://localhost:8080/api/admin/search/solr/migrate-library-schema + ``` + +4. **Monitor logs** for successful migration + +## Verification Steps + +After migration, verify that library separation is working: + +1. **Test with multiple libraries**: + - Log in with Library A password + - Add/search content + - Log in with Library B password + - Confirm Library A content is not visible + +2. **Check Solr directly** (if accessible): + ```bash + # Should show documents with libraryId field + curl "http://solr:8983/solr/storycove_stories/select?q=*:*&fl=id,title,libraryId&rows=5" + ``` + +3. **Monitor application logs** for any library separation errors + +## Troubleshooting + +### "unknown field 'libraryId'" Error + +This means the Solr schema wasn't updated. Solutions: +- Use Option 1 (volume reset) for clean restart +- Use Option 2 (Schema API) to add the field manually +- Check that schema files contain the libraryId field definition + +### Migration Endpoint Returns Error + +Common causes: +- Solr is not available (check connectivity) +- No active library context (ensure user is authenticated) +- Insufficient permissions (check JWT token/authentication) + +### Search Results Still Mixed + +This indicates incomplete migration: +- Clear all Solr data and reindex completely +- Verify that all documents have libraryId field +- Check that search queries include library filters + +## Environment-Specific Notes + +### Development +- Use Option 1 (volume reset) for simplicity +- Data loss is acceptable in dev environments + +### Staging +- Use Option 2 or 3 to test production migration procedures +- Verify migration process before applying to production + +### Production +- **Always backup data first** +- Use Option 2 (Schema API) or Option 3 (Admin endpoint) +- Plan for brief performance impact during reindexing +- Monitor system resources during bulk reindexing + +## Performance Considerations + +- **Reindexing time**: Depends on data size (typically 1000 docs/second) +- **Memory usage**: May increase during bulk indexing +- **Search performance**: Minimal impact from library filtering +- **Storage**: Slight increase due to libraryId field + +## Rollback Plan + +If issues occur: + +1. **Immediate**: Restart Solr to previous state (if using Option 1) +2. **Schema revert**: Remove libraryId field via Schema API +3. **Code rollback**: Deploy previous version without library separation +4. **Data restore**: Restore from backup if necessary + +This migration enables proper multi-tenant isolation while maintaining search performance and functionality. \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/controller/AdminSearchController.java b/backend/src/main/java/com/storycove/controller/AdminSearchController.java index 246147d..2846e86 100644 --- a/backend/src/main/java/com/storycove/controller/AdminSearchController.java +++ b/backend/src/main/java/com/storycove/controller/AdminSearchController.java @@ -160,4 +160,71 @@ public class AdminSearchController { )); } } + + /** + * Migrate to library-aware Solr schema. + * This endpoint handles the migration from non-library-aware to library-aware indexing. + * It clears existing data and reindexes with library context. + */ + @PostMapping("/solr/migrate-library-schema") + public ResponseEntity> migrateLibrarySchema() { + try { + logger.info("Starting Solr library schema migration"); + + if (!searchServiceAdapter.isSearchServiceAvailable()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "Solr is not available or healthy" + )); + } + + if (solrService == null) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "Solr service not available" + )); + } + + logger.info("Clearing existing Solr data for library schema migration"); + + // Note: This assumes the libraryId field has been added to the Solr schema + // Either manually or via schema restart with updated schema files + + // Clear existing data that doesn't have libraryId + solrService.recreateIndices(); + + // Get all data and reindex with library context + List allStories = storyService.findAllWithAssociations(); + List allAuthors = authorService.findAllWithStories(); + + logger.info("Reindexing {} stories and {} authors with library context", + allStories.size(), allAuthors.size()); + + // Bulk index everything (will now include libraryId from current library context) + solrService.bulkIndexStories(allStories); + solrService.bulkIndexAuthors(allAuthors); + + int totalIndexed = allStories.size() + allAuthors.size(); + + logger.info("Solr library schema migration completed successfully"); + + return ResponseEntity.ok(Map.of( + "success", true, + "message", String.format("Library schema migration completed. Reindexed %d stories and %d authors with library context.", + allStories.size(), allAuthors.size()), + "storiesCount", allStories.size(), + "authorsCount", allAuthors.size(), + "totalCount", totalIndexed, + "note", "Ensure libraryId field exists in Solr schema before running this migration" + )); + + } catch (Exception e) { + logger.error("Error during Solr library schema migration", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "error", "Library schema migration failed: " + e.getMessage(), + "details", "Make sure the libraryId field has been added to both stories and authors Solr cores" + )); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/SolrService.java b/backend/src/main/java/com/storycove/service/SolrService.java index e40e5f8..cba1d3a 100644 --- a/backend/src/main/java/com/storycove/service/SolrService.java +++ b/backend/src/main/java/com/storycove/service/SolrService.java @@ -15,7 +15,6 @@ import org.apache.solr.client.solrj.response.UpdateResponse; import org.apache.solr.common.SolrDocument; import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.SolrInputDocument; -import org.apache.solr.common.params.ModifiableSolrParams; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -50,6 +49,9 @@ public class SolrService { @Lazy private ReadingTimeService readingTimeService; + @Autowired + private LibraryService libraryService; + @PostConstruct public void initializeCores() { if (!isAvailable()) { @@ -287,6 +289,12 @@ public class SolrService { doc.addField("updatedAt", formatDateTime(story.getUpdatedAt())); doc.addField("dateAdded", formatDateTime(story.getCreatedAt())); + // Add library ID for multi-tenant separation + String currentLibraryId = getCurrentLibraryId(); + if (currentLibraryId != null) { + doc.addField("libraryId", currentLibraryId); + } + return doc; } @@ -320,6 +328,12 @@ public class SolrService { doc.addField("createdAt", formatDateTime(author.getCreatedAt())); doc.addField("updatedAt", formatDateTime(author.getUpdatedAt())); + // Add library ID for multi-tenant separation + String currentLibraryId = getCurrentLibraryId(); + if (currentLibraryId != null) { + doc.addField("libraryId", currentLibraryId); + } + return doc; } @@ -336,6 +350,20 @@ public class SolrService { return solrClient != null; } + /** + * Get current library ID for multi-tenant document separation. + * Falls back to "default" if no library is active. + */ + private String getCurrentLibraryId() { + try { + String libraryId = libraryService.getCurrentLibraryId(); + return libraryId != null ? libraryId : "default"; + } catch (Exception e) { + logger.warn("Could not get current library ID, using 'default': {}", e.getMessage()); + return "default"; + } + } + public boolean testConnection() { if (!isAvailable()) { return false; @@ -465,6 +493,10 @@ public class SolrService { solrQuery.setRows(limit); + // Add library filter for multi-tenant separation + String currentLibraryId = getCurrentLibraryId(); + solrQuery.addFilterQuery("libraryId:\"" + escapeQueryChars(currentLibraryId) + "\""); + // Sort by storyCount if available, otherwise by name solrQuery.setSort("storyCount", SolrQuery.ORDER.desc); solrQuery.addSort("name", SolrQuery.ORDER.asc); @@ -496,6 +528,10 @@ public class SolrService { solrQuery.setFacetMinCount(1); solrQuery.setFacetLimit(limit); + // Add library filter for multi-tenant separation + String currentLibraryId = getCurrentLibraryId(); + solrQuery.addFilterQuery("libraryId:\"" + escapeQueryChars(currentLibraryId) + "\""); + QueryResponse response = solrClient.query(properties.getCores().getStories(), solrQuery); return response.getFacetField("tagNames_facet").getValues().stream() @@ -523,6 +559,10 @@ public class SolrService { solrQuery.setSort("random_" + System.currentTimeMillis(), SolrQuery.ORDER.asc); } + // Add library filter for multi-tenant separation + String currentLibraryId = getCurrentLibraryId(); + solrQuery.addFilterQuery("libraryId:\"" + escapeQueryChars(currentLibraryId) + "\""); + QueryResponse response = solrClient.query(properties.getCores().getStories(), solrQuery); if (response.getResults().size() > 0) { @@ -750,6 +790,10 @@ public class SolrService { List filters = new ArrayList<>(); + // Library filter - ensure multi-tenant data separation + String currentLibraryId = getCurrentLibraryId(); + filters.add("libraryId:\"" + escapeQueryChars(currentLibraryId) + "\""); + // Tag filters - use facet field for exact matching if (tags != null && !tags.isEmpty()) { String tagFilter = tags.stream() diff --git a/frontend/src/components/stories/PortableTextEditor.tsx b/frontend/src/components/stories/PortableTextEditor.tsx index c8b693b..132a16d 100644 --- a/frontend/src/components/stories/PortableTextEditor.tsx +++ b/frontend/src/components/stories/PortableTextEditor.tsx @@ -448,9 +448,15 @@ function EditorContent({ ); const [isScrollable, setIsScrollable] = useState(true); // Default to scrollable - // Sync HTML value with prop changes + // Sync HTML value with prop changes (but not for internal changes) useEffect(() => { - debug.log('🔄 Editor value changed:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) }); + // Skip re-initialization if this change came from the editor itself + if (isInternalChange.current) { + debug.log('🔄 Skipping re-initialization for internal change'); + return; + } + + debug.log('🔄 Editor value changed externally:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) }); setPortableTextValue(htmlToPortableTextBlocks(value)); }, [value]); @@ -459,13 +465,23 @@ function EditorContent({ debug.log('📝 Portable text blocks updated:', { blockCount: portableTextValue.length, blocks: portableTextValue }); }, [portableTextValue]); + // Track if changes are coming from internal editor changes + const isInternalChange = useRef(false); + // Handle content changes using the EventListenerPlugin const handleEditorChange = useCallback((event: any) => { if (event.type === 'mutation') { debug.log('📝 Editor content changed via EventListener:', { valueLength: event.value?.length }); const html = portableTextToHtml(event.value); debug.log('📝 Converted to HTML:', { htmlLength: html.length, htmlPreview: html.substring(0, 200) }); + + // Mark this as an internal change to prevent re-initialization + isInternalChange.current = true; onChange(html); + // Reset flag after a short delay to allow external changes + setTimeout(() => { + isInternalChange.current = false; + }, 100); } }, [onChange]); diff --git a/solr/authors/conf/managed-schema b/solr/authors/conf/managed-schema index 3daf881..15a57ec 100755 --- a/solr/authors/conf/managed-schema +++ b/solr/authors/conf/managed-schema @@ -70,6 +70,9 @@ + + + diff --git a/solr/stories/conf/managed-schema b/solr/stories/conf/managed-schema index 0c9fd90..c0cfaab 100755 --- a/solr/stories/conf/managed-schema +++ b/solr/stories/conf/managed-schema @@ -77,6 +77,9 @@ + + +