|
@@ -0,0 +1,302 @@
|
|
|
|
|
+import { Request, Response } from 'express';
|
|
|
|
|
+import { BaseEndpointComponent, EndpointMethod } from '../core/types';
|
|
|
|
|
+import { logger } from '../../utils/logger';
|
|
|
|
|
+import { ShopDatabase } from '../../database/Database';
|
|
|
|
|
+import { QdrantService } from '../../services/QdrantService';
|
|
|
|
|
+import { ContentExtractor } from '../../scraper/ContentExtractor';
|
|
|
|
|
+import { QdrantEmbeddingWorkflow } from '../../services/QdrantEmbeddingWorkflow';
|
|
|
|
|
+import { createHash } from 'crypto';
|
|
|
|
|
+import type { AppConfig } from '../../config';
|
|
|
|
|
+
|
|
|
|
|
+export class ContentEndpoint extends BaseEndpointComponent {
|
|
|
|
|
+ private qdrantService?: QdrantService;
|
|
|
|
|
+ private contentExtractor: ContentExtractor;
|
|
|
|
|
+ private embeddingWorkflow?: QdrantEmbeddingWorkflow;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(private db: ShopDatabase, private config?: AppConfig) {
|
|
|
|
|
+ super();
|
|
|
|
|
+ this.contentExtractor = new ContentExtractor();
|
|
|
|
|
+ if (config) {
|
|
|
|
|
+ this.qdrantService = new QdrantService(config);
|
|
|
|
|
+ this.embeddingWorkflow = new QdrantEmbeddingWorkflow(config, db);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ getEndpoints(): EndpointMethod[] {
|
|
|
|
|
+ return [
|
|
|
|
|
+ this.createEndpoint(
|
|
|
|
|
+ 'GET',
|
|
|
|
|
+ '/api/shops/:id/content',
|
|
|
|
|
+ this.getAllContent.bind(this),
|
|
|
|
|
+ {
|
|
|
|
|
+ summary: 'List all content for a shop',
|
|
|
|
|
+ description: 'Retrieves all content (from sitemap and custom URLs) for a shop, including disabled content',
|
|
|
|
|
+ tags: ['Content'],
|
|
|
|
|
+ parameters: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: 'id',
|
|
|
|
|
+ in: 'path',
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ required: true,
|
|
|
|
|
+ description: 'Shop ID (internal UUID or custom UUID)'
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: 'include_disabled',
|
|
|
|
|
+ in: 'query',
|
|
|
|
|
+ type: 'boolean',
|
|
|
|
|
+ required: false,
|
|
|
|
|
+ description: 'Include disabled content in the results (default: true)'
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ responses: {
|
|
|
|
|
+ '200': {
|
|
|
|
|
+ description: 'List of content items',
|
|
|
|
|
+ schema: {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ shop_id: { type: 'string' },
|
|
|
|
|
+ content: {
|
|
|
|
|
+ type: 'array',
|
|
|
|
|
+ items: {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ id: { type: 'string' },
|
|
|
|
|
+ url: { type: 'string' },
|
|
|
|
|
+ content_type: { type: 'string' },
|
|
|
|
|
+ enabled: { type: 'boolean' },
|
|
|
|
|
+ title: { type: 'string' },
|
|
|
|
|
+ created_at: { type: 'string' },
|
|
|
|
|
+ updated_at: { type: 'string' }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ '404': {
|
|
|
|
|
+ description: 'Shop not found'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ requiresAuth: true
|
|
|
|
|
+ }
|
|
|
|
|
+ ),
|
|
|
|
|
+ this.createEndpoint(
|
|
|
|
|
+ 'PATCH',
|
|
|
|
|
+ '/api/shops/:id/content/:contentId',
|
|
|
|
|
+ this.updateContent.bind(this),
|
|
|
|
|
+ {
|
|
|
|
|
+ summary: 'Enable or disable content',
|
|
|
|
|
+ description: 'Updates the enabled status of a content item. When disabling, removes content from database and Qdrant. When re-enabling, triggers a rescrape.',
|
|
|
|
|
+ tags: ['Content'],
|
|
|
|
|
+ parameters: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: 'id',
|
|
|
|
|
+ in: 'path',
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ required: true,
|
|
|
|
|
+ description: 'Shop ID (internal UUID or custom UUID)'
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: 'contentId',
|
|
|
|
|
+ in: 'path',
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ required: true,
|
|
|
|
|
+ description: 'Content ID'
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ requestBody: {
|
|
|
|
|
+ required: true,
|
|
|
|
|
+ schema: {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ enabled: {
|
|
|
|
|
+ type: 'boolean',
|
|
|
|
|
+ description: 'Whether to enable or disable the content'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ required: ['enabled']
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ responses: {
|
|
|
|
|
+ '200': {
|
|
|
|
|
+ description: 'Content status updated',
|
|
|
|
|
+ schema: {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ message: { type: 'string' },
|
|
|
|
|
+ content_id: { type: 'string' },
|
|
|
|
|
+ enabled: { type: 'boolean' },
|
|
|
|
|
+ action_taken: { type: 'string' }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ '400': {
|
|
|
|
|
+ description: 'Invalid request data'
|
|
|
|
|
+ },
|
|
|
|
|
+ '404': {
|
|
|
|
|
+ description: 'Shop or content not found'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ requiresAuth: true
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async getAllContent(req: Request, res: Response): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!this.db) {
|
|
|
|
|
+ res.status(503).json({ error: 'Database not available' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { id } = req.params;
|
|
|
|
|
+ const includeDisabled = req.query.include_disabled !== 'false'; // Default true
|
|
|
|
|
+
|
|
|
|
|
+ const shop = this.db.getShopByAnyId(id);
|
|
|
|
|
+ if (!shop) {
|
|
|
|
|
+ res.status(404).json({ error: 'Shop not found' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const content = this.db.getAllShopContent(shop.id, includeDisabled);
|
|
|
|
|
+
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ shop_id: shop.id,
|
|
|
|
|
+ content: content.map(c => ({
|
|
|
|
|
+ id: c.id,
|
|
|
|
|
+ url: c.url,
|
|
|
|
|
+ content_type: c.content_type,
|
|
|
|
|
+ enabled: c.enabled,
|
|
|
|
|
+ title: c.title,
|
|
|
|
|
+ created_at: c.created_at,
|
|
|
|
|
+ updated_at: c.updated_at
|
|
|
|
|
+ }))
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Error fetching content', error);
|
|
|
|
|
+ res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async updateContent(req: Request, res: Response): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!this.db) {
|
|
|
|
|
+ res.status(503).json({ error: 'Database not available' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { id, contentId } = req.params;
|
|
|
|
|
+ const { enabled } = req.body;
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof enabled !== 'boolean') {
|
|
|
|
|
+ res.status(400).json({ error: 'enabled field must be a boolean' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Verify shop exists
|
|
|
|
|
+ const shop = this.db.getShopByAnyId(id);
|
|
|
|
|
+ if (!shop) {
|
|
|
|
|
+ res.status(404).json({ error: 'Shop not found' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Verify content exists and belongs to this shop
|
|
|
|
|
+ const content = this.db.getContentById(contentId);
|
|
|
|
|
+ if (!content) {
|
|
|
|
|
+ res.status(404).json({ error: 'Content not found' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (content.shop_id !== shop.id) {
|
|
|
|
|
+ res.status(404).json({ error: 'Content does not belong to this shop' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Check if status is already correct
|
|
|
|
|
+ if (content.enabled === enabled) {
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ message: `Content is already ${enabled ? 'enabled' : 'disabled'}`,
|
|
|
|
|
+ content_id: contentId,
|
|
|
|
|
+ enabled,
|
|
|
|
|
+ action_taken: 'none'
|
|
|
|
|
+ });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let actionTaken = '';
|
|
|
|
|
+
|
|
|
|
|
+ if (!enabled) {
|
|
|
|
|
+ // Disabling: clear content and Qdrant data
|
|
|
|
|
+ // First, delete from Qdrant if available
|
|
|
|
|
+ if (this.qdrantService) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const embeddings = this.db.getShopEmbeddings(contentId);
|
|
|
|
|
+ for (const emb of embeddings) {
|
|
|
|
|
+ await this.qdrantService.deletePointsByContentId(emb.collection_name, contentId);
|
|
|
|
|
+ logger.info(`Deleted Qdrant points for content ${contentId} from collection ${emb.collection_name}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`Failed to delete Qdrant points for content ${contentId}:`, error);
|
|
|
|
|
+ // Continue with database cleanup even if Qdrant cleanup fails
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Then delete from database (clears content but keeps URL record)
|
|
|
|
|
+ this.db.deleteContentData(contentId);
|
|
|
|
|
+ actionTaken = 'content_and_embeddings_deleted';
|
|
|
|
|
+ logger.info(`Disabled content ${contentId} and cleared data for shop ${shop.id}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Re-enabling: update the flag and scrape immediately
|
|
|
|
|
+ this.db.setContentEnabled(contentId, true);
|
|
|
|
|
+ logger.info(`Re-enabled content ${contentId} for shop ${shop.id}, starting immediate scrape`);
|
|
|
|
|
+
|
|
|
|
|
+ // Scrape the content immediately
|
|
|
|
|
+ try {
|
|
|
|
|
+ const extractionResult = await this.contentExtractor.extractContent(content.url);
|
|
|
|
|
+
|
|
|
|
|
+ // Save the scraped content
|
|
|
|
|
+ const saveResult = this.db.saveContent(
|
|
|
|
|
+ shop.id,
|
|
|
|
|
+ content.content_type as 'shipping' | 'contacts' | 'terms' | 'faq',
|
|
|
|
|
+ content.url,
|
|
|
|
|
+ extractionResult.content,
|
|
|
|
|
+ extractionResult.title
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // If embedding workflow is available, process embeddings
|
|
|
|
|
+ if (this.embeddingWorkflow) {
|
|
|
|
|
+ const contentHash = createHash('sha256').update(extractionResult.content).digest('hex');
|
|
|
|
|
+ await this.embeddingWorkflow.processContentForEmbedding(
|
|
|
|
|
+ shop.id,
|
|
|
|
|
+ saveResult.contentId,
|
|
|
|
|
+ content.content_type as 'shipping' | 'contacts' | 'terms' | 'faq',
|
|
|
|
|
+ content.url,
|
|
|
|
|
+ extractionResult.content,
|
|
|
|
|
+ extractionResult.title,
|
|
|
|
|
+ contentHash,
|
|
|
|
|
+ saveResult.changed
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ actionTaken = 'rescraped_and_saved';
|
|
|
|
|
+ logger.info(`Successfully re-scraped content ${contentId} for shop ${shop.id}`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`Failed to re-scrape content ${contentId}:`, error);
|
|
|
|
|
+ actionTaken = 'enabled_but_scrape_failed';
|
|
|
|
|
+ // Note: Content is still enabled, it will be retried on next scheduled scrape
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ message: `Content ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
|
|
|
+ content_id: contentId,
|
|
|
|
|
+ enabled,
|
|
|
|
|
+ action_taken: actionTaken
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Error updating content status', error);
|
|
|
|
|
+ res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|