From 9d7d1abb49a2afabd7c448e223c55389de56170c Mon Sep 17 00:00:00 2001 From: William Tso Date: Wed, 12 Mar 2025 15:49:47 +0800 Subject: [PATCH] cleaning up api --- backend/src/controllers/commentsController.ts | 207 +++- .../src/controllers/influencersController.ts | 229 ++++- backend/src/controllers/postsController.ts | 303 ++++++ backend/src/controllers/projectsController.ts | 340 +++++++ backend/src/index.ts | 45 +- backend/src/routes/comments.ts | 4 +- backend/src/routes/influencers.ts | 17 +- backend/src/routes/posts.ts | 687 +------------ backend/src/routes/projects.ts | 21 + backend/src/swagger/index.ts | 960 +++++++++++++++++- 10 files changed, 1974 insertions(+), 839 deletions(-) create mode 100644 backend/src/controllers/postsController.ts create mode 100644 backend/src/controllers/projectsController.ts create mode 100644 backend/src/routes/projects.ts diff --git a/backend/src/controllers/commentsController.ts b/backend/src/controllers/commentsController.ts index 2548e83..f25627e 100644 --- a/backend/src/controllers/commentsController.ts +++ b/backend/src/controllers/commentsController.ts @@ -1,27 +1,43 @@ import { Context } from 'hono'; import supabase from '../utils/supabase'; +// Get all comments with filtering and pagination export const getComments = async (c: Context) => { try { const { post_id, limit = '10', offset = '0' } = c.req.query(); - let query; + let query = supabase + .from('comments') + .select(` + comment_id, + post_id, + user_id, + content, + sentiment_score, + created_at, + updated_at, + posts ( + post_id, + title, + platform + ) + `, { count: 'exact' }); + // Apply filters if (post_id) { - // 获取特定帖子的评论 - query = supabase.rpc('get_comments_for_post', { post_id_param: post_id }); - } else { - // 获取所有评论 - query = supabase.rpc('get_comments_with_posts'); + query = query.eq('post_id', post_id); } - // 应用分页 - query = query.range(Number(offset), Number(offset) + Number(limit) - 1); + // Apply pagination and sorting + query = query + .order('created_at', { ascending: false }) + .range(Number(offset), Number(offset) + Number(limit) - 1); const { data: comments, error, count } = await query; if (error) { - return c.json({ error: error.message }, 500); + console.error('Error fetching comments:', error); + return c.json({ error: 'Failed to fetch comments' }, 500); } return c.json({ @@ -31,90 +47,169 @@ export const getComments = async (c: Context) => { offset: Number(offset) }); } catch (error) { + console.error('Error in getComments:', error); return c.json({ error: 'Internal server error' }, 500); } }; -export const createComment = async (c: Context) => { +// Get a specific comment by ID +export const getCommentById = async (c: Context) => { try { - const { post_id, content } = await c.req.json(); - const user_id = c.get('user')?.id; - - if (!user_id) { - return c.json({ error: 'Unauthorized' }, 401); - } - + const commentId = c.req.param('comment_id'); + const { data: comment, error } = await supabase .from('comments') - .insert({ - post_id, - content, - user_id - }) .select(` comment_id, + post_id, + user_id, content, sentiment_score, created_at, updated_at, - post_id, - user_id + posts ( + post_id, + title, + platform + ) `) - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - // 获取用户信息 - const { data: userProfile, error: userError } = await supabase - .from('user_profiles') - .select('id, full_name, avatar_url') - .eq('id', user_id) + .eq('comment_id', commentId) .single(); - if (!userError && userProfile) { - comment.user_profile = userProfile; + if (error) { + console.error('Error fetching comment:', error); + return c.json({ error: 'Failed to fetch comment' }, 500); } - - return c.json(comment, 201); + + if (!comment) { + return c.json({ error: 'Comment not found' }, 404); + } + + return c.json(comment); } catch (error) { + console.error('Error in getCommentById:', error); return c.json({ error: 'Internal server error' }, 500); } }; -export const deleteComment = async (c: Context) => { +// Create a new comment +export const createComment = async (c: Context) => { try { - const { comment_id } = c.req.param(); + const { post_id, content } = await c.req.json(); const user_id = c.get('user')?.id; - - if (!user_id) { - return c.json({ error: 'Unauthorized' }, 401); + + // Validate required fields + if (!post_id || !content) { + return c.json({ error: 'post_id and content are required' }, 400); } - - // Check if the comment belongs to the user - const { data: comment, error: fetchError } = await supabase + + // Check if post exists + const { data: post, error: postError } = await supabase + .from('posts') + .select('post_id') + .eq('post_id', post_id) + .single(); + + if (postError || !post) { + return c.json({ error: 'Post not found' }, 404); + } + + const { data: comment, error } = await supabase .from('comments') + .insert({ + post_id, + user_id, + content + }) .select() - .eq('comment_id', comment_id) + .single(); + + if (error) { + console.error('Error creating comment:', error); + return c.json({ error: 'Failed to create comment' }, 500); + } + + return c.json(comment, 201); + } catch (error) { + console.error('Error in createComment:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Update an existing comment +export const updateComment = async (c: Context) => { + try { + const commentId = c.req.param('comment_id'); + const { content } = await c.req.json(); + const user_id = c.get('user')?.id; + + // Validate required fields + if (!content) { + return c.json({ error: 'content is required' }, 400); + } + + // Check if comment exists and belongs to the user + const { data: existingComment, error: existingCommentError } = await supabase + .from('comments') + .select('comment_id') + .eq('comment_id', commentId) .eq('user_id', user_id) .single(); - - if (fetchError || !comment) { + + if (existingCommentError || !existingComment) { return c.json({ error: 'Comment not found or unauthorized' }, 404); } + + const { data: comment, error } = await supabase + .from('comments') + .update({ content }) + .eq('comment_id', commentId) + .select() + .single(); + + if (error) { + console.error('Error updating comment:', error); + return c.json({ error: 'Failed to update comment' }, 500); + } + + return c.json(comment); + } catch (error) { + console.error('Error in updateComment:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; - const { error: deleteError } = await supabase +// Delete a comment +export const deleteComment = async (c: Context) => { + try { + const commentId = c.req.param('comment_id'); + const user_id = c.get('user')?.id; + + // Check if comment exists and belongs to the user + const { data: existingComment, error: existingCommentError } = await supabase + .from('comments') + .select('comment_id') + .eq('comment_id', commentId) + .eq('user_id', user_id) + .single(); + + if (existingCommentError || !existingComment) { + return c.json({ error: 'Comment not found or unauthorized' }, 404); + } + + const { error } = await supabase .from('comments') .delete() - .eq('comment_id', comment_id); - - if (deleteError) { - return c.json({ error: deleteError.message }, 500); + .eq('comment_id', commentId); + + if (error) { + console.error('Error deleting comment:', error); + return c.json({ error: 'Failed to delete comment' }, 500); } - + return c.body(null, 204); } catch (error) { + console.error('Error in deleteComment:', error); return c.json({ error: 'Internal server error' }, 500); } }; \ No newline at end of file diff --git a/backend/src/controllers/influencersController.ts b/backend/src/controllers/influencersController.ts index 57ae8d0..46bd525 100644 --- a/backend/src/controllers/influencersController.ts +++ b/backend/src/controllers/influencersController.ts @@ -1,7 +1,8 @@ import { Context } from 'hono'; import supabase from '../utils/supabase'; -export const getInfluencers = async (c: Context) => { +// Get all influencers with filtering and pagination +const getInfluencers = async (c: Context) => { try { const { platform, @@ -20,12 +21,13 @@ export const getInfluencers = async (c: Context) => { name, platform, profile_url, + external_id, followers_count, video_count, platform_count, created_at, updated_at - `); + `, { count: 'exact' }); // Apply filters if (platform) { @@ -49,6 +51,7 @@ export const getInfluencers = async (c: Context) => { const { data: influencers, error, count } = await query; if (error) { + console.error('Error fetching influencers:', error); return c.json({ error: error.message }, 500); } @@ -59,11 +62,13 @@ export const getInfluencers = async (c: Context) => { offset: Number(offset) }); } catch (error) { + console.error('Error in getInfluencers:', error); return c.json({ error: 'Internal server error' }, 500); } }; -export const getInfluencerById = async (c: Context) => { +// Get a specific influencer by ID +const getInfluencerById = async (c: Context) => { try { const { influencer_id } = c.req.param(); @@ -74,32 +79,30 @@ export const getInfluencerById = async (c: Context) => { name, platform, profile_url, + external_id, followers_count, video_count, platform_count, created_at, - updated_at, - posts ( - post_id, - title, - description, - published_at - ) + updated_at `) .eq('influencer_id', influencer_id) .single(); if (error) { + console.error('Error fetching influencer:', error); return c.json({ error: 'Influencer not found' }, 404); } return c.json(influencer); } catch (error) { + console.error('Error in getInfluencerById:', error); return c.json({ error: 'Internal server error' }, 500); } }; -export const getInfluencerStats = async (c: Context) => { +// Get aggregated stats for influencers +const getInfluencerStats = async (c: Context) => { try { const { platform } = c.req.query(); @@ -114,6 +117,7 @@ export const getInfluencerStats = async (c: Context) => { const { data: stats, error } = await query; if (error) { + console.error('Error fetching influencer stats:', error); return c.json({ error: error.message }, 500); } @@ -126,11 +130,212 @@ export const getInfluencerStats = async (c: Context) => { ), average_videos: Math.round( stats.reduce((sum: number, item: any) => sum + (item.video_count || 0), 0) / (stats.length || 1) - ) + ), + platform_distribution: stats.reduce((acc: Record, item: any) => { + const platform = item.platform || 'unknown'; + acc[platform] = (acc[platform] || 0) + 1; + return acc; + }, {}) }; return c.json(aggregatedStats); } catch (error) { + console.error('Error in getInfluencerStats:', error); return c.json({ error: 'Internal server error' }, 500); } +}; + +// Create a new influencer +const createInfluencer = async (c: Context) => { + try { + const { name, platform, profile_url, external_id, followers_count, video_count } = await c.req.json(); + + if (!name) { + return c.json({ error: 'Name is required' }, 400); + } + + const { data: influencer, error } = await supabase + .from('influencers') + .insert({ + name, + platform, + profile_url, + external_id, + followers_count: followers_count || 0, + video_count: video_count || 0 + }) + .select() + .single(); + + if (error) { + if (error.code === '23505') { // Unique constraint violation + return c.json({ error: 'An influencer with this external ID already exists' }, 409); + } + console.error('Error creating influencer:', error); + return c.json({ error: 'Failed to create influencer' }, 500); + } + + return c.json(influencer, 201); + } catch (error) { + console.error('Error in createInfluencer:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Update an existing influencer +const updateInfluencer = async (c: Context) => { + try { + const { influencer_id } = c.req.param(); + const { name, platform, profile_url, external_id, followers_count, video_count } = await c.req.json(); + + // Check if influencer exists + const { data: existingInfluencer, error: fetchError } = await supabase + .from('influencers') + .select('influencer_id') + .eq('influencer_id', influencer_id) + .single(); + + if (fetchError || !existingInfluencer) { + return c.json({ error: 'Influencer not found' }, 404); + } + + const updateData: any = {}; + if (name !== undefined) updateData.name = name; + if (platform !== undefined) updateData.platform = platform; + if (profile_url !== undefined) updateData.profile_url = profile_url; + if (external_id !== undefined) updateData.external_id = external_id; + if (followers_count !== undefined) updateData.followers_count = followers_count; + if (video_count !== undefined) updateData.video_count = video_count; + updateData.updated_at = new Date().toISOString(); + + const { data: updatedInfluencer, error: updateError } = await supabase + .from('influencers') + .update(updateData) + .eq('influencer_id', influencer_id) + .select() + .single(); + + if (updateError) { + if (updateError.code === '23505') { // Unique constraint violation + return c.json({ error: 'An influencer with this external ID already exists' }, 409); + } + console.error('Error updating influencer:', updateError); + return c.json({ error: 'Failed to update influencer' }, 500); + } + + return c.json(updatedInfluencer); + } catch (error) { + console.error('Error in updateInfluencer:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Delete an influencer +const deleteInfluencer = async (c: Context) => { + try { + const { influencer_id } = c.req.param(); + + // Check if influencer exists + const { data: existingInfluencer, error: fetchError } = await supabase + .from('influencers') + .select('influencer_id') + .eq('influencer_id', influencer_id) + .single(); + + if (fetchError || !existingInfluencer) { + return c.json({ error: 'Influencer not found' }, 404); + } + + // Check if influencer has any posts + const { data: posts, error: postsError } = await supabase + .from('posts') + .select('post_id') + .eq('influencer_id', influencer_id) + .limit(1); + + if (!postsError && posts && posts.length > 0) { + return c.json({ + error: 'Cannot delete influencer with existing posts. Delete their posts first or use the force parameter.' + }, 400); + } + + // Delete the influencer + const { error: deleteError } = await supabase + .from('influencers') + .delete() + .eq('influencer_id', influencer_id); + + if (deleteError) { + console.error('Error deleting influencer:', deleteError); + return c.json({ error: 'Failed to delete influencer' }, 500); + } + + return c.body(null, 204); + } catch (error) { + console.error('Error in deleteInfluencer:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Get all posts for a specific influencer +const getInfluencerPosts = async (c: Context) => { + try { + const { influencer_id } = c.req.param(); + const { limit = '20', offset = '0' } = c.req.query(); + + // Check if influencer exists + const { data: influencer, error: influencerError } = await supabase + .from('influencers') + .select('influencer_id, name') + .eq('influencer_id', influencer_id) + .single(); + + if (influencerError || !influencer) { + return c.json({ error: 'Influencer not found' }, 404); + } + + // Get posts for the influencer + const { data: posts, error: postsError, count } = await supabase + .from('posts') + .select(` + post_id, + platform, + post_url, + title, + description, + published_at, + created_at, + updated_at + `, { count: 'exact' }) + .eq('influencer_id', influencer_id) + .order('published_at', { ascending: false }) + .range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1); + + if (postsError) { + console.error('Error fetching influencer posts:', postsError); + return c.json({ error: 'Failed to fetch influencer posts' }, 500); + } + + return c.json({ + influencer_id, + influencer_name: influencer.name, + posts, + count, + limit: parseInt(limit), + offset: parseInt(offset) + }); + } catch (error) { + console.error('Error in getInfluencerPosts:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +export default { + getInfluencers, + getInfluencerById, + getInfluencerStats, + createInfluencer, + updateInfluencer, + deleteInfluencer, + getInfluencerPosts }; \ No newline at end of file diff --git a/backend/src/controllers/postsController.ts b/backend/src/controllers/postsController.ts new file mode 100644 index 0000000..6cc9bc6 --- /dev/null +++ b/backend/src/controllers/postsController.ts @@ -0,0 +1,303 @@ +import { Context } from 'hono'; +import supabase from '../utils/supabase'; + +// Get all posts with filtering and pagination +const getPosts = async (c: Context) => { + try { + const { + influencer_id, + platform, + limit = '10', + offset = '0', + sort_by = 'published_at', + sort_order = 'desc' + } = c.req.query(); + + let query = supabase + .from('posts') + .select(` + post_id, + influencer_id, + platform, + post_url, + title, + description, + published_at, + created_at, + updated_at, + influencers ( + name, + profile_url, + followers_count + ) + `, { count: 'exact' }); + + // Apply filters + if (influencer_id) { + query = query.eq('influencer_id', influencer_id); + } + if (platform) { + query = query.eq('platform', platform); + } + + // Apply sorting + if (sort_by && ['published_at', 'created_at'].includes(sort_by)) { + query = query.order(sort_by, { ascending: sort_order === 'asc' }); + } + + // Apply pagination + query = query.range(Number(offset), Number(offset) + Number(limit) - 1); + + const { data: posts, error, count } = await query; + + if (error) { + console.error('Error fetching posts:', error); + return c.json({ error: 'Failed to fetch posts' }, 500); + } + + return c.json({ + posts, + count, + limit: Number(limit), + offset: Number(offset) + }); + } catch (error) { + console.error('Error in getPosts:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Get a specific post by ID +const getPostById = async (c: Context) => { + try { + const postId = c.req.param('post_id'); + + const { data: post, error } = await supabase + .from('posts') + .select(` + post_id, + influencer_id, + platform, + post_url, + title, + description, + published_at, + created_at, + updated_at, + influencers ( + name, + profile_url, + followers_count + ) + `) + .eq('post_id', postId) + .single(); + + if (error) { + console.error('Error fetching post:', error); + return c.json({ error: 'Failed to fetch post' }, 500); + } + + if (!post) { + return c.json({ error: 'Post not found' }, 404); + } + + return c.json(post); + } catch (error) { + console.error('Error in getPostById:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Create a new post +const createPost = async (c: Context) => { + try { + const { influencer_id, platform, post_url, title, description, published_at } = await c.req.json(); + + // Validate required fields + if (!influencer_id || !post_url) { + return c.json({ error: 'influencer_id and post_url are required' }, 400); + } + + // Check if influencer exists + const { data: influencer, error: influencerError } = await supabase + .from('influencers') + .select('influencer_id') + .eq('influencer_id', influencer_id) + .single(); + + if (influencerError || !influencer) { + return c.json({ error: 'Influencer not found' }, 404); + } + + // Check if post URL already exists + const { data: existingPost, error: existingPostError } = await supabase + .from('posts') + .select('post_id') + .eq('post_url', post_url) + .maybeSingle(); + + if (existingPost) { + return c.json({ error: 'Post with this URL already exists' }, 409); + } + + const { data: post, error } = await supabase + .from('posts') + .insert({ + influencer_id, + platform, + post_url, + title, + description, + published_at: published_at || new Date().toISOString() + }) + .select() + .single(); + + if (error) { + console.error('Error creating post:', error); + return c.json({ error: 'Failed to create post' }, 500); + } + + return c.json(post, 201); + } catch (error) { + console.error('Error in createPost:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Update an existing post +const updatePost = async (c: Context) => { + try { + const postId = c.req.param('post_id'); + const { platform, post_url, title, description, published_at } = await c.req.json(); + + // Check if post exists + const { data: existingPost, error: existingPostError } = await supabase + .from('posts') + .select('post_id') + .eq('post_id', postId) + .single(); + + if (existingPostError || !existingPost) { + return c.json({ error: 'Post not found' }, 404); + } + + // Check if post URL already exists (if updating URL) + if (post_url) { + const { data: duplicatePost, error: duplicatePostError } = await supabase + .from('posts') + .select('post_id') + .eq('post_url', post_url) + .neq('post_id', postId) + .maybeSingle(); + + if (duplicatePost) { + return c.json({ error: 'Another post with this URL already exists' }, 409); + } + } + + const { data: post, error } = await supabase + .from('posts') + .update({ + platform, + post_url, + title, + description, + published_at + }) + .eq('post_id', postId) + .select() + .single(); + + if (error) { + console.error('Error updating post:', error); + return c.json({ error: 'Failed to update post' }, 500); + } + + return c.json(post); + } catch (error) { + console.error('Error in updatePost:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Delete a post +const deletePost = async (c: Context) => { + try { + const postId = c.req.param('post_id'); + + // Check if post exists + const { data: existingPost, error: existingPostError } = await supabase + .from('posts') + .select('post_id') + .eq('post_id', postId) + .single(); + + if (existingPostError || !existingPost) { + return c.json({ error: 'Post not found' }, 404); + } + + // Delete post + const { error } = await supabase + .from('posts') + .delete() + .eq('post_id', postId); + + if (error) { + console.error('Error deleting post:', error); + return c.json({ error: 'Failed to delete post' }, 500); + } + + return c.body(null, 204); + } catch (error) { + console.error('Error in deletePost:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Get comments for a specific post +const getPostComments = async (c: Context) => { + try { + const postId = c.req.param('post_id'); + const { limit = '10', offset = '0' } = c.req.query(); + + const { data: comments, error, count } = await supabase + .from('comments') + .select(` + comment_id, + content, + sentiment_score, + created_at, + updated_at, + user_id + `, { count: 'exact' }) + .eq('post_id', postId) + .order('created_at', { ascending: false }) + .range(Number(offset), Number(offset) + Number(limit) - 1); + + if (error) { + console.error('Error fetching post comments:', error); + return c.json({ error: 'Failed to fetch comments' }, 500); + } + + return c.json({ + comments, + count, + limit: Number(limit), + offset: Number(offset) + }); + } catch (error) { + console.error('Error in getPostComments:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +export default { + getPosts, + getPostById, + createPost, + updatePost, + deletePost, + getPostComments +}; \ No newline at end of file diff --git a/backend/src/controllers/projectsController.ts b/backend/src/controllers/projectsController.ts new file mode 100644 index 0000000..c0e6532 --- /dev/null +++ b/backend/src/controllers/projectsController.ts @@ -0,0 +1,340 @@ +import { Context } from 'hono'; +import supabase from '../utils/supabase'; + +// Get all projects with optional filtering +const getProjects = async (c: Context) => { + try { + const { limit = '20', offset = '0', created_by } = c.req.query(); + + let query = supabase + .from('projects') + .select('id, name, description, created_by, created_at, updated_at', { count: 'exact' }); + + // Apply filtering if user ID is provided + if (created_by) { + query = query.eq('created_by', created_by); + } + + // Apply pagination and ordering + query = query + .order('created_at', { ascending: false }) + .range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1); + + const { data: projects, error, count } = await query; + + if (error) { + console.error('Error fetching projects:', error); + return c.json({ error: 'Failed to fetch projects' }, 500); + } + + return c.json({ + projects, + count, + limit: parseInt(limit), + offset: parseInt(offset) + }); + } catch (error) { + console.error('Error in getProjects:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Get a specific project by ID +const getProjectById = async (c: Context) => { + try { + const projectId = c.req.param('id'); + + const { data: project, error } = await supabase + .from('projects') + .select('id, name, description, created_by, created_at, updated_at') + .eq('id', projectId) + .single(); + + if (error) { + console.error('Error fetching project:', error); + return c.json({ error: 'Project not found' }, 404); + } + + return c.json(project); + } catch (error) { + console.error('Error in getProjectById:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Create a new project +const createProject = async (c: Context) => { + try { + const { name, description } = await c.req.json(); + const user = c.get('user'); + + if (!name) { + return c.json({ error: 'Project name is required' }, 400); + } + + const { data: project, error } = await supabase + .from('projects') + .insert({ + name, + description, + created_by: user.id + }) + .select() + .single(); + + if (error) { + console.error('Error creating project:', error); + return c.json({ error: 'Failed to create project' }, 500); + } + + return c.json(project, 201); + } catch (error) { + console.error('Error in createProject:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Update an existing project +const updateProject = async (c: Context) => { + try { + const projectId = c.req.param('id'); + const { name, description } = await c.req.json(); + const user = c.get('user'); + + // Check if project exists and user has permission + const { data: existingProject, error: fetchError } = await supabase + .from('projects') + .select('id, created_by') + .eq('id', projectId) + .single(); + + if (fetchError || !existingProject) { + return c.json({ error: 'Project not found' }, 404); + } + + if (existingProject.created_by !== user.id) { + return c.json({ error: 'You do not have permission to update this project' }, 403); + } + + // Update the project + const { data: updatedProject, error: updateError } = await supabase + .from('projects') + .update({ + name, + description, + updated_at: new Date().toISOString() + }) + .eq('id', projectId) + .select() + .single(); + + if (updateError) { + console.error('Error updating project:', updateError); + return c.json({ error: 'Failed to update project' }, 500); + } + + return c.json(updatedProject); + } catch (error) { + console.error('Error in updateProject:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Delete a project +const deleteProject = async (c: Context) => { + try { + const projectId = c.req.param('id'); + const user = c.get('user'); + + // Check if project exists and user has permission + const { data: existingProject, error: fetchError } = await supabase + .from('projects') + .select('id, created_by') + .eq('id', projectId) + .single(); + + if (fetchError || !existingProject) { + return c.json({ error: 'Project not found' }, 404); + } + + if (existingProject.created_by !== user.id) { + return c.json({ error: 'You do not have permission to delete this project' }, 403); + } + + // Delete the project + const { error: deleteError } = await supabase + .from('projects') + .delete() + .eq('id', projectId); + + if (deleteError) { + console.error('Error deleting project:', deleteError); + return c.json({ error: 'Failed to delete project' }, 500); + } + + return c.body(null, 204); + } catch (error) { + console.error('Error in deleteProject:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Get all influencers associated with a project +const getProjectInfluencers = async (c: Context) => { + try { + const projectId = c.req.param('id'); + + // First check if the project exists + const { data: project, error: projectError } = await supabase + .from('projects') + .select('id') + .eq('id', projectId) + .single(); + + if (projectError) { + return c.json({ error: 'Project not found' }, 404); + } + + // Get the influencers associated with the project + const { data: projectInfluencers, error } = await supabase + .from('project_influencers') + .select(` + id, + influencer_id, + created_at, + influencers:influencer_id ( + influencer_id, + name, + platform, + profile_url, + followers_count, + video_count + ) + `) + .eq('project_id', projectId); + + if (error) { + console.error('Error fetching project influencers:', error); + return c.json({ error: 'Failed to fetch project influencers' }, 500); + } + + return c.json(projectInfluencers); + } catch (error) { + console.error('Error in getProjectInfluencers:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Add an influencer to a project +const addInfluencerToProject = async (c: Context) => { + try { + const projectId = c.req.param('id'); + const { influencer_id } = await c.req.json(); + const user = c.get('user'); + + if (!influencer_id) { + return c.json({ error: 'Influencer ID is required' }, 400); + } + + // Check if the project exists and the user has permission + const { data: project, error: projectError } = await supabase + .from('projects') + .select('id, created_by') + .eq('id', projectId) + .single(); + + if (projectError || !project) { + return c.json({ error: 'Project not found' }, 404); + } + + if (project.created_by !== user.id) { + return c.json({ error: 'You do not have permission to modify this project' }, 403); + } + + // Check if the influencer exists + const { data: influencer, error: influencerError } = await supabase + .from('influencers') + .select('influencer_id') + .eq('influencer_id', influencer_id) + .single(); + + if (influencerError || !influencer) { + return c.json({ error: 'Influencer not found' }, 404); + } + + // Add the influencer to the project + const { data: projectInfluencer, error } = await supabase + .from('project_influencers') + .insert({ + project_id: projectId, + influencer_id + }) + .select() + .single(); + + if (error) { + if (error.code === '23505') { // Unique constraint violation + return c.json({ error: 'Influencer is already in this project' }, 409); + } + console.error('Error adding influencer to project:', error); + return c.json({ error: 'Failed to add influencer to project' }, 500); + } + + return c.json(projectInfluencer, 201); + } catch (error) { + console.error('Error in addInfluencerToProject:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +// Remove an influencer from a project +const removeInfluencerFromProject = async (c: Context) => { + try { + const projectId = c.req.param('id'); + const influencerId = c.req.param('influencer_id'); + const user = c.get('user'); + + // Check if the project exists and the user has permission + const { data: project, error: projectError } = await supabase + .from('projects') + .select('id, created_by') + .eq('id', projectId) + .single(); + + if (projectError || !project) { + return c.json({ error: 'Project not found' }, 404); + } + + if (project.created_by !== user.id) { + return c.json({ error: 'You do not have permission to modify this project' }, 403); + } + + // Remove the influencer from the project + const { error } = await supabase + .from('project_influencers') + .delete() + .eq('project_id', projectId) + .eq('influencer_id', influencerId); + + if (error) { + console.error('Error removing influencer from project:', error); + return c.json({ error: 'Failed to remove influencer from project' }, 500); + } + + return c.body(null, 204); + } catch (error) { + console.error('Error in removeInfluencerFromProject:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}; + +export default { + getProjects, + getProjectById, + createProject, + updateProject, + deleteProject, + getProjectInfluencers, + addInfluencerToProject, + removeInfluencerFromProject +}; \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 6dfae1e..aab0acc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,10 +10,11 @@ import postsRouter from './routes/posts'; import projectCommentsRouter from './routes/projectComments'; import commentsRouter from './routes/comments'; import influencersRouter from './routes/influencers'; +import projectsRouter from './routes/projects'; import { connectRedis } from './utils/redis'; import { initClickHouse } from './utils/clickhouse'; import { initWorkers } from './utils/queue'; -import { initDatabase, createSampleData, checkDatabaseConnection } from './utils/initDatabase'; +import { checkDatabaseConnection } from './utils/initDatabase'; import { createSwaggerUI } from './swagger'; import { initScheduledTaskWorkers } from './utils/scheduledTasks'; @@ -39,42 +40,6 @@ app.get('/', (c) => { }); }); -// 数据库初始化路由 -app.post('/api/admin/init-db', async (c) => { - try { - const result = await initDatabase(); - return c.json({ - success: result, - message: result ? 'Database initialized successfully' : 'Database initialization failed' - }); - } catch (error) { - console.error('Error initializing database:', error); - return c.json({ - success: false, - message: 'Error initializing database', - error: error instanceof Error ? error.message : String(error) - }, 500); - } -}); - -// 创建测试数据路由 -app.post('/api/admin/create-sample-data', async (c) => { - try { - const result = await createSampleData(); - return c.json({ - success: result, - message: result ? 'Sample data created successfully' : 'Sample data creation failed' - }); - } catch (error) { - console.error('Error creating sample data:', error); - return c.json({ - success: false, - message: 'Error creating sample data', - error: error instanceof Error ? error.message : String(error) - }, 500); - } -}); - // Routes app.route('/api/auth', authRouter); app.route('/api/analytics', analyticsRouter); @@ -83,6 +48,7 @@ app.route('/api/posts', postsRouter); app.route('/api/project-comments', projectCommentsRouter); app.route('/api/comments', commentsRouter); app.route('/api/influencers', influencersRouter); +app.route('/api/projects', projectsRouter); // Swagger UI const swaggerApp = createSwaggerUI(); @@ -117,9 +83,6 @@ const startServer = async () => { console.log('Some features may not work correctly if database is not properly set up'); } - console.log('NOTICE: Database will NOT be automatically initialized on startup'); - console.log('Use /api/admin/init-db endpoint to manually initialize the database if needed'); - // Initialize workers for background processing console.log('🏗️ Initializing workers...'); const workers = { @@ -138,8 +101,6 @@ const startServer = async () => { console.log(`Server running at http://localhost:${port}`); console.log(`Swagger UI available at http://localhost:${port}/swagger`); - console.log(`Initialize database at http://localhost:${port}/api/admin/init-db (POST)`); - console.log(`Create sample data at http://localhost:${port}/api/admin/create-sample-data (POST)`); // Handle graceful shutdown const shutdown = async () => { diff --git a/backend/src/routes/comments.ts b/backend/src/routes/comments.ts index 1973ac1..c18b6fe 100644 --- a/backend/src/routes/comments.ts +++ b/backend/src/routes/comments.ts @@ -1,14 +1,16 @@ import { Hono } from 'hono'; -import { getComments, createComment, deleteComment } from '../controllers/commentsController'; import { authMiddleware } from '../middlewares/auth'; +import { getComments, getCommentById, createComment, updateComment, deleteComment } from '../controllers/commentsController'; const commentsRouter = new Hono(); // Public routes commentsRouter.get('/', getComments); +commentsRouter.get('/:comment_id', getCommentById); // Protected routes commentsRouter.post('/', authMiddleware, createComment); +commentsRouter.put('/:comment_id', authMiddleware, updateComment); commentsRouter.delete('/:comment_id', authMiddleware, deleteComment); export default commentsRouter; \ No newline at end of file diff --git a/backend/src/routes/influencers.ts b/backend/src/routes/influencers.ts index 6396ce5..da6e518 100644 --- a/backend/src/routes/influencers.ts +++ b/backend/src/routes/influencers.ts @@ -1,11 +1,20 @@ import { Hono } from 'hono'; -import { getInfluencers, getInfluencerById, getInfluencerStats } from '../controllers/influencersController'; +import { authMiddleware } from '../middlewares/auth'; +import influencersController from '../controllers/influencersController'; const influencersRouter = new Hono(); // Public routes -influencersRouter.get('/', getInfluencers); -influencersRouter.get('/stats', getInfluencerStats); -influencersRouter.get('/:influencer_id', getInfluencerById); +influencersRouter.get('/', influencersController.getInfluencers); +influencersRouter.get('/stats', influencersController.getInfluencerStats); +influencersRouter.get('/:influencer_id', influencersController.getInfluencerById); + +// Protected routes +influencersRouter.post('/', authMiddleware, influencersController.createInfluencer); +influencersRouter.put('/:influencer_id', authMiddleware, influencersController.updateInfluencer); +influencersRouter.delete('/:influencer_id', authMiddleware, influencersController.deleteInfluencer); + +// Related data +influencersRouter.get('/:influencer_id/posts', influencersController.getInfluencerPosts); export default influencersRouter; \ No newline at end of file diff --git a/backend/src/routes/posts.ts b/backend/src/routes/posts.ts index 7972860..599e805 100644 --- a/backend/src/routes/posts.ts +++ b/backend/src/routes/posts.ts @@ -1,686 +1,17 @@ import { Hono } from 'hono'; import { authMiddleware } from '../middlewares/auth'; -import supabase from '../utils/supabase'; -import clickhouse from '../utils/clickhouse'; -import { getRedisClient } from '../utils/redis'; - -// Define user type -interface User { - id: string; - email: string; - name?: string; -} - -// Define stats type -interface PostStats { - post_id: string; - views: number | null; - likes: number | null; -} - -// Extend Hono's Context type -declare module 'hono' { - interface ContextVariableMap { - user: User; - } -} +import postsController from '../controllers/postsController'; const postsRouter = new Hono(); -// Apply auth middleware to most routes -postsRouter.use('*', authMiddleware); +// Public routes +postsRouter.get('/', postsController.getPosts); +postsRouter.get('/:post_id', postsController.getPostById); +postsRouter.get('/:post_id/comments', postsController.getPostComments); -// 创建新帖子 -postsRouter.post('/', async (c) => { - try { - const { - influencer_id, - platform, - post_url, - title, - description, - published_at - } = await c.req.json(); - - if (!influencer_id || !platform || !post_url) { - return c.json({ - error: 'influencer_id, platform, and post_url are required' - }, 400); - } - - // 验证平台 - const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook']; - if (!validPlatforms.includes(platform)) { - return c.json({ - error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}` - }, 400); - } - - // 检查帖子URL是否已存在 - const { data: existingPost, error: checkError } = await supabase - .from('posts') - .select('*') - .eq('post_url', post_url) - .single(); - - if (!checkError && existingPost) { - return c.json({ - error: 'Post with this URL already exists', - post: existingPost - }, 409); - } - - // 创建新帖子 - const { data: post, error } = await supabase - .from('posts') - .insert({ - influencer_id, - platform, - post_url, - title, - description, - published_at: published_at || new Date().toISOString() - }) - .select() - .single(); - - if (error) { - console.error('Error creating post:', error); - return c.json({ error: 'Failed to create post' }, 500); - } - - return c.json({ - message: 'Post created successfully', - post - }, 201); - } catch (error) { - console.error('Error creating post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 获取帖子列表 -postsRouter.get('/', async (c) => { - try { - const { - influencer_id, - platform, - limit = '20', - offset = '0', - sort = 'published_at', - order = 'desc' - } = c.req.query(); - - // 构建查询 - let query = supabase.from('posts').select(` - *, - influencer:influencers(name, platform, profile_url, followers_count) - `); - - // 添加过滤条件 - if (influencer_id) { - query = query.eq('influencer_id', influencer_id); - } - - if (platform) { - query = query.eq('platform', platform); - } - - // 添加排序和分页 - query = query.order(sort, { ascending: order === 'asc' }); - query = query.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1); - - // 执行查询 - const { data, error, count } = await query; - - if (error) { - console.error('Error fetching posts:', error); - return c.json({ error: 'Failed to fetch posts' }, 500); - } - - // 获取帖子的统计数据 - if (data && data.length > 0) { - const postIds = data.map(post => post.post_id); - - // 尝试从缓存获取数据 - const redis = await getRedisClient(); - const cachedStats: PostStats[] = await Promise.all( - postIds.map(async (postId) => { - const [views, likes] = await Promise.all([ - redis.get(`post:views:${postId}`), - redis.get(`post:likes:${postId}`) - ]); - - return { - post_id: postId, - views: views ? parseInt(views) : null, - likes: likes ? parseInt(likes) : null - }; - }) - ); - - // 找出缓存中没有的帖子ID - const missingIds = postIds.filter(id => { - const stat = cachedStats.find(s => s.post_id === id); - return stat?.views === null || stat?.likes === null; - }); - - // 如果有缺失的统计数据,从ClickHouse获取 - if (missingIds.length > 0) { - try { - // 查询帖子的观看数 - const viewsResult = await clickhouse.query({ - query: ` - SELECT - post_id, - SUM(metric_value) AS views - FROM events - WHERE - post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND - event_type = 'post_view_change' - GROUP BY post_id - ` - }); - - // 查询帖子的点赞数 - const likesResult = await clickhouse.query({ - query: ` - SELECT - post_id, - SUM(metric_value) AS likes - FROM events - WHERE - post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND - event_type = 'post_like_change' - GROUP BY post_id - ` - }); - - // 处理结果 - const viewsData = 'rows' in viewsResult ? viewsResult.rows : []; - const likesData = 'rows' in likesResult ? likesResult.rows : []; - - // 更新缓存并填充统计数据 - for (const viewStat of viewsData) { - if (viewStat && typeof viewStat === 'object' && 'post_id' in viewStat && 'views' in viewStat) { - // 更新缓存 - await redis.set(`post:views:${viewStat.post_id}`, String(viewStat.views)); - - // 更新缓存统计数据 - const cacheStat = cachedStats.find(s => s.post_id === viewStat.post_id); - if (cacheStat) { - cacheStat.views = Number(viewStat.views); - } - } - } - - for (const likeStat of likesData) { - if (likeStat && typeof likeStat === 'object' && 'post_id' in likeStat && 'likes' in likeStat) { - // 更新缓存 - await redis.set(`post:likes:${likeStat.post_id}`, String(likeStat.likes)); - - // 更新缓存统计数据 - const cacheStat = cachedStats.find(s => s.post_id === likeStat.post_id); - if (cacheStat) { - cacheStat.likes = Number(likeStat.likes); - } - } - } - } catch (chError) { - console.error('Error fetching stats from ClickHouse:', chError); - } - } - - // 合并统计数据到帖子数据 - data.forEach(post => { - const stats = cachedStats.find(s => s.post_id === post.post_id); - post.stats = { - views: stats?.views || 0, - likes: stats?.likes || 0 - }; - }); - } - - return c.json({ - posts: data || [], - total: count || 0, - limit: parseInt(limit), - offset: parseInt(offset) - }); - } catch (error) { - console.error('Error fetching posts:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 获取单个帖子详情 -postsRouter.get('/:id', async (c) => { - try { - const postId = c.req.param('id'); - - // 获取帖子详情 - const { data: post, error } = await supabase - .from('posts') - .select(` - *, - influencer:influencers(name, platform, profile_url, followers_count) - `) - .eq('post_id', postId) - .single(); - - if (error) { - console.error('Error fetching post:', error); - return c.json({ error: 'Failed to fetch post' }, 500); - } - - if (!post) { - return c.json({ error: 'Post not found' }, 404); - } - - // 获取帖子统计数据 - try { - // 先尝试从Redis缓存获取 - const redis = await getRedisClient(); - const [cachedViews, cachedLikes] = await Promise.all([ - redis.get(`post:views:${postId}`), - redis.get(`post:likes:${postId}`) - ]); - - // 如果缓存中有数据,直接使用 - if (cachedViews !== null && cachedLikes !== null) { - post.stats = { - views: parseInt(cachedViews), - likes: parseInt(cachedLikes) - }; - } else { - // 如果缓存中没有,从ClickHouse获取 - // 查询帖子的观看数 - const viewsResult = await clickhouse.query({ - query: ` - SELECT SUM(metric_value) AS views - FROM events - WHERE - post_id = ? AND - event_type = 'post_view_change' - `, - values: [postId] - }); - - // 查询帖子的点赞数 - const likesResult = await clickhouse.query({ - query: ` - SELECT SUM(metric_value) AS likes - FROM events - WHERE - post_id = ? AND - event_type = 'post_like_change' - `, - values: [postId] - }); - - // 处理结果 - let viewsData = 0; - if ('rows' in viewsResult && viewsResult.rows.length > 0 && viewsResult.rows[0] && typeof viewsResult.rows[0] === 'object' && 'views' in viewsResult.rows[0]) { - viewsData = Number(viewsResult.rows[0].views) || 0; - } - - let likesData = 0; - if ('rows' in likesResult && likesResult.rows.length > 0 && likesResult.rows[0] && typeof likesResult.rows[0] === 'object' && 'likes' in likesResult.rows[0]) { - likesData = Number(likesResult.rows[0].likes) || 0; - } - - // 更新缓存 - await redis.set(`post:views:${postId}`, String(viewsData)); - await redis.set(`post:likes:${postId}`, String(likesData)); - - // 添加统计数据 - post.stats = { - views: viewsData, - likes: likesData - }; - } - - // 获取互动时间线 - const timelineResult = await clickhouse.query({ - query: ` - SELECT - toDate(timestamp) as date, - event_type, - SUM(metric_value) as value - FROM events - WHERE - post_id = ? AND - event_type IN ('post_view_change', 'post_like_change') - GROUP BY date, event_type - ORDER BY date ASC - `, - values: [postId] - }); - - const timelineData = 'rows' in timelineResult ? timelineResult.rows : []; - - // 添加时间线数据 - post.timeline = timelineData; - - // 获取评论数量 - const { count } = await supabase - .from('comments') - .select('*', { count: 'exact', head: true }) - .eq('post_id', postId); - - post.comment_count = count || 0; - } catch (statsError) { - console.error('Error fetching post stats:', statsError); - // 继续返回帖子数据,但没有统计信息 - post.stats = { views: 0, likes: 0 }; - post.timeline = []; - post.comment_count = 0; - } - - return c.json(post); - } catch (error) { - console.error('Error fetching post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 更新帖子 -postsRouter.put('/:id', async (c) => { - try { - const postId = c.req.param('id'); - const user = c.get('user'); - const { title, description } = await c.req.json(); - - // 先检查帖子是否存在 - const { data: existingPost, error: fetchError } = await supabase - .from('posts') - .select('*') - .eq('post_id', postId) - .single(); - - if (fetchError || !existingPost) { - return c.json({ error: 'Post not found' }, 404); - } - - // 更新帖子 - const { data: updatedPost, error } = await supabase - .from('posts') - .update({ - title, - description, - updated_at: new Date().toISOString() - }) - .eq('post_id', postId) - .select() - .single(); - - if (error) { - console.error('Error updating post:', error); - return c.json({ error: 'Failed to update post' }, 500); - } - - return c.json({ - message: 'Post updated successfully', - post: updatedPost - }); - } catch (error) { - console.error('Error updating post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 删除帖子 -postsRouter.delete('/:id', async (c) => { - try { - const postId = c.req.param('id'); - const user = c.get('user'); - - // 删除帖子 - const { error } = await supabase - .from('posts') - .delete() - .eq('post_id', postId); - - if (error) { - console.error('Error deleting post:', error); - return c.json({ error: 'Failed to delete post' }, 500); - } - - // 清除缓存 - try { - const redis = await getRedisClient(); - await Promise.all([ - redis.del(`post:views:${postId}`), - redis.del(`post:likes:${postId}`) - ]); - } catch (cacheError) { - console.error('Error clearing cache:', cacheError); - } - - return c.json({ - message: 'Post deleted successfully' - }); - } catch (error) { - console.error('Error deleting post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 获取帖子的评论 -postsRouter.get('/:id/comments', async (c) => { - try { - const postId = c.req.param('id'); - const { limit = '20', offset = '0' } = c.req.query(); - - // 获取评论 - const { data: comments, error, count } = await supabase - .from('comments') - .select('*', { count: 'exact' }) - .eq('post_id', postId) - .order('created_at', { ascending: false }) - .range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1); - - if (error) { - console.error('Error fetching comments:', error); - return c.json({ error: 'Failed to fetch comments' }, 500); - } - - // 如果有评论,获取用户信息 - if (comments && comments.length > 0) { - const userIds = [...new Set(comments.map(comment => comment.user_id))]; - - // 获取用户信息 - const { data: userProfiles, error: userError } = await supabase - .from('user_profiles') - .select('id, full_name, avatar_url') - .in('id', userIds); - - if (!userError && userProfiles) { - // 将用户信息添加到评论中 - comments.forEach(comment => { - const userProfile = userProfiles.find(profile => profile.id === comment.user_id); - comment.user_profile = userProfile || null; - }); - } else { - console.error('Error fetching user profiles:', userError); - } - } - - return c.json({ - comments: comments || [], - total: count || 0, - limit: parseInt(limit), - offset: parseInt(offset) - }); - } catch (error) { - console.error('Error fetching comments:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 添加评论到帖子 -postsRouter.post('/:id/comments', async (c) => { - try { - const postId = c.req.param('id'); - const user = c.get('user'); - const { content, sentiment_score } = await c.req.json(); - - if (!content) { - return c.json({ error: 'Comment content is required' }, 400); - } - - // 创建评论 - const { data: comment, error } = await supabase - .from('comments') - .insert({ - post_id: postId, - user_id: user.id, - content, - sentiment_score: sentiment_score || 0 - }) - .select() - .single(); - - if (error) { - console.error('Error creating comment:', error); - return c.json({ error: 'Failed to create comment' }, 500); - } - - // 尝试记录评论事件到ClickHouse - try { - // 获取帖子信息 - const { data: post } = await supabase - .from('posts') - .select('influencer_id, platform') - .eq('post_id', postId) - .single(); - - if (post) { - await clickhouse.query({ - query: ` - INSERT INTO events ( - influencer_id, - post_id, - platform, - event_type, - metric_value, - event_metadata - ) VALUES (?, ?, ?, 'comment', ?, ?) - `, - values: [ - post.influencer_id, - postId, - post.platform, - 1, - JSON.stringify({ - comment_id: comment.comment_id, - user_id: user.id, - sentiment_score: sentiment_score || 0 - }) - ] - }); - } - } catch (eventError) { - console.error('Error recording comment event:', eventError); - // 不影响主流程,继续返回评论数据 - } - - return c.json({ - message: 'Comment added successfully', - comment - }, 201); - } catch (error) { - console.error('Error adding comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 更新评论 -postsRouter.put('/comments/:id', async (c) => { - try { - const commentId = c.req.param('id'); - const user = c.get('user'); - const { content, sentiment_score } = await c.req.json(); - - // 先检查评论是否存在且属于当前用户 - const { data: existingComment, error: fetchError } = await supabase - .from('comments') - .select('*') - .eq('comment_id', commentId) - .eq('user_id', user.id) - .single(); - - if (fetchError || !existingComment) { - return c.json({ - error: 'Comment not found or you do not have permission to update it' - }, 404); - } - - // 更新评论 - const { data: updatedComment, error } = await supabase - .from('comments') - .update({ - content, - sentiment_score: sentiment_score !== undefined ? sentiment_score : existingComment.sentiment_score, - updated_at: new Date().toISOString() - }) - .eq('comment_id', commentId) - .select() - .single(); - - if (error) { - console.error('Error updating comment:', error); - return c.json({ error: 'Failed to update comment' }, 500); - } - - return c.json({ - message: 'Comment updated successfully', - comment: updatedComment - }); - } catch (error) { - console.error('Error updating comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); - -// 删除评论 -postsRouter.delete('/comments/:id', async (c) => { - try { - const commentId = c.req.param('id'); - const user = c.get('user'); - - // 先检查评论是否存在且属于当前用户 - const { data: existingComment, error: fetchError } = await supabase - .from('comments') - .select('*') - .eq('comment_id', commentId) - .eq('user_id', user.id) - .single(); - - if (fetchError || !existingComment) { - return c.json({ - error: 'Comment not found or you do not have permission to delete it' - }, 404); - } - - // 删除评论 - const { error } = await supabase - .from('comments') - .delete() - .eq('comment_id', commentId); - - if (error) { - console.error('Error deleting comment:', error); - return c.json({ error: 'Failed to delete comment' }, 500); - } - - return c.json({ - message: 'Comment deleted successfully' - }); - } catch (error) { - console.error('Error deleting comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); +// Protected routes +postsRouter.post('/', authMiddleware, postsController.createPost); +postsRouter.put('/:post_id', authMiddleware, postsController.updatePost); +postsRouter.delete('/:post_id', authMiddleware, postsController.deletePost); export default postsRouter; \ No newline at end of file diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts new file mode 100644 index 0000000..bd88731 --- /dev/null +++ b/backend/src/routes/projects.ts @@ -0,0 +1,21 @@ +import { Hono } from 'hono'; +import { authMiddleware } from '../middlewares/auth'; +import projectsController from '../controllers/projectsController'; + +const projectsRouter = new Hono(); + +// Public routes +projectsRouter.get('/', projectsController.getProjects); +projectsRouter.get('/:id', projectsController.getProjectById); + +// Protected routes - require authentication +projectsRouter.post('/', authMiddleware, projectsController.createProject); +projectsRouter.put('/:id', authMiddleware, projectsController.updateProject); +projectsRouter.delete('/:id', authMiddleware, projectsController.deleteProject); + +// Project influencers management +projectsRouter.get('/:id/influencers', projectsController.getProjectInfluencers); +projectsRouter.post('/:id/influencers', authMiddleware, projectsController.addInfluencerToProject); +projectsRouter.delete('/:id/influencers/:influencer_id', authMiddleware, projectsController.removeInfluencerFromProject); + +export default projectsRouter; \ No newline at end of file diff --git a/backend/src/swagger/index.ts b/backend/src/swagger/index.ts index 5248faf..e76e538 100644 --- a/backend/src/swagger/index.ts +++ b/backend/src/swagger/index.ts @@ -16,11 +16,38 @@ export const openAPISpec = { description: 'Local development server', }, ], + tags: [ + { + name: 'Core', + description: 'Core API endpoints' + }, + { + name: 'Influencers', + description: 'Influencer management endpoints' + }, + { + name: 'Posts', + description: 'Post management endpoints' + }, + { + name: 'Comments', + description: 'Comment management endpoints' + }, + { + name: 'Projects', + description: 'Project management endpoints' + }, + { + name: 'Analytics', + description: 'Analytics and reporting endpoints' + } + ], paths: { '/': { get: { summary: 'Health check', description: 'Returns the API status', + tags: ['Core'], responses: { '200': { description: 'API is running', @@ -44,6 +71,7 @@ export const openAPISpec = { post: { summary: 'Register a new user', description: 'Creates a new user account', + tags: ['Core'], requestBody: { required: true, content: { @@ -116,6 +144,7 @@ export const openAPISpec = { post: { summary: 'Login user', description: 'Authenticates a user and returns a JWT token', + tags: ['Core'], requestBody: { required: true, content: { @@ -196,6 +225,7 @@ export const openAPISpec = { get: { summary: 'Verify token', description: 'Verifies a JWT token', + tags: ['Core'], security: [ { bearerAuth: [], @@ -255,6 +285,7 @@ export const openAPISpec = { post: { summary: 'Track view event', description: 'Records a view event for content', + tags: ['Analytics'], security: [ { bearerAuth: [], @@ -334,6 +365,7 @@ export const openAPISpec = { post: { summary: 'Track like event', description: 'Records a like or unlike event for content', + tags: ['Analytics'], security: [ { bearerAuth: [], @@ -414,6 +446,7 @@ export const openAPISpec = { post: { summary: 'Track follow event', description: 'Records a follow or unfollow event for a user', + tags: ['Analytics'], security: [ { bearerAuth: [], @@ -494,6 +527,7 @@ export const openAPISpec = { get: { summary: 'Get content analytics', description: 'Returns analytics data for a specific content', + tags: ['Analytics'], security: [ { bearerAuth: [], @@ -560,6 +594,7 @@ export const openAPISpec = { get: { summary: 'Get user analytics', description: 'Returns analytics data for a specific user', + tags: ['Analytics'], security: [ { bearerAuth: [], @@ -648,9 +683,9 @@ export const openAPISpec = { }, '/api/posts': { get: { - summary: '获取帖子列表', - description: '返回分页的帖子列表,支持过滤和排序', - security: [{ bearerAuth: [] }], + summary: 'Get all posts', + description: 'Returns a list of posts with pagination and filtering options', + tags: ['Posts'], parameters: [ { name: 'influencer_id', @@ -737,8 +772,9 @@ export const openAPISpec = { } }, post: { - summary: '创建新帖子', - description: '创建一个新的帖子', + summary: 'Create a new post', + description: 'Creates a new post in the system', + tags: ['Posts'], security: [{ bearerAuth: [] }], requestBody: { required: true, @@ -818,14 +854,14 @@ export const openAPISpec = { } } }, - '/api/posts/{id}': { + '/api/posts/{post_id}': { get: { - summary: '获取单个帖子详情', - description: '返回指定ID的帖子详情,包括统计数据和时间线', - security: [{ bearerAuth: [] }], + summary: 'Get post by ID', + description: 'Returns a specific post by ID', + tags: ['Posts'], parameters: [ { - name: 'id', + name: 'post_id', in: 'path', description: '帖子ID', required: true, @@ -866,12 +902,12 @@ export const openAPISpec = { } }, put: { - summary: '更新帖子', - description: '更新指定ID的帖子信息', - security: [{ bearerAuth: [] }], + summary: 'Update post', + description: 'Updates an existing post', + tags: ['Posts'], parameters: [ { - name: 'id', + name: 'post_id', in: 'path', description: '帖子ID', required: true, @@ -932,12 +968,12 @@ export const openAPISpec = { } }, delete: { - summary: '删除帖子', - description: '删除指定ID的帖子', - security: [{ bearerAuth: [] }], + summary: 'Delete post', + description: 'Removes a post from the system', + tags: ['Posts'], parameters: [ { - name: 'id', + name: 'post_id', in: 'path', description: '帖子ID', required: true, @@ -971,14 +1007,14 @@ export const openAPISpec = { } } }, - '/api/posts/{id}/comments': { + '/api/posts/{post_id}/comments': { get: { - summary: '获取帖子评论', - description: '返回指定帖子的评论列表', - security: [{ bearerAuth: [] }], + summary: 'Get comments for post', + description: 'Returns all comments associated with a specific post', + tags: ['Posts', 'Comments'], parameters: [ { - name: 'id', + name: 'post_id', in: 'path', description: '帖子ID', required: true, @@ -1039,7 +1075,7 @@ export const openAPISpec = { security: [{ bearerAuth: [] }], parameters: [ { - name: 'id', + name: 'post_id', in: 'path', description: '帖子ID', required: true, @@ -1220,9 +1256,9 @@ export const openAPISpec = { }, '/api/comments': { get: { + summary: 'Get all comments', + description: 'Returns a list of comments with pagination and filtering options', tags: ['Comments'], - summary: 'Get comments', - description: 'Retrieve a list of comments with optional filtering by post_id', parameters: [ { name: 'post_id', @@ -1286,9 +1322,9 @@ export const openAPISpec = { } }, post: { - tags: ['Comments'], - summary: 'Create a comment', + summary: 'Create a new comment', description: 'Create a new comment on a post', + tags: ['Comments'], security: [ { bearerAuth: [] @@ -1332,15 +1368,92 @@ export const openAPISpec = { } }, '/api/comments/{comment_id}': { - delete: { + get: { + summary: 'Get comment by ID', + description: 'Returns a specific comment by ID', tags: ['Comments'], - summary: 'Delete a comment', - description: 'Delete a comment by ID (only for comment owner)', - security: [ + parameters: [ { - bearerAuth: [] + name: 'comment_id', + in: 'path', + description: 'Comment ID to get', + required: true, + schema: { + type: 'string', + format: 'uuid' + } } ], + responses: { + '200': { + description: 'Comment found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Comment' + } + } + } + }, + '404': { + description: 'Comment not found' + } + } + }, + put: { + summary: 'Update comment', + description: 'Updates an existing comment', + tags: ['Comments'], + parameters: [ + { + name: 'comment_id', + in: 'path', + description: 'Comment ID to update', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['content'], + properties: { + content: { type: 'string' }, + sentiment_score: { type: 'number' } + } + } + } + } + }, + responses: { + '200': { + description: 'Comment updated successfully', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Comment' + } + } + } + }, + '404': { + description: 'Comment not found' + }, + '500': { + description: 'Internal server error' + } + } + }, + delete: { + summary: 'Delete comment', + description: 'Removes a comment from the system', + tags: ['Comments'], parameters: [ { name: 'comment_id', @@ -1354,23 +1467,33 @@ export const openAPISpec = { } ], responses: { - '204': { - description: 'Comment deleted successfully' - }, - '401': { - description: 'Unauthorized' + '200': { + description: 'Comment deleted successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string' } + } + } + } + } }, '404': { - description: 'Comment not found or unauthorized' + description: 'Comment not found' + }, + '500': { + description: 'Internal server error' } } } }, '/api/influencers': { get: { + summary: 'Get all influencers', + description: 'Returns a list of influencers with pagination and filtering options', tags: ['Influencers'], - summary: 'Get influencers', - description: 'Retrieve a list of influencers with optional filtering and sorting', parameters: [ { name: 'platform', @@ -1472,13 +1595,74 @@ export const openAPISpec = { } } } + }, + post: { + summary: 'Create a new influencer', + description: 'Creates a new influencer in the system', + tags: ['Influencers'], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name', 'platform', 'profile_url', 'followers_count', 'video_count'], + properties: { + name: { type: 'string' }, + platform: { type: 'string' }, + profile_url: { type: 'string' }, + followers_count: { type: 'integer' }, + video_count: { type: 'integer' } + } + } + } + } + }, + responses: { + '201': { + description: 'Influencer created successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string' }, + influencer: { + $ref: '#/components/schemas/Influencer' + } + } + } + } + } + }, + '400': { + description: 'Bad request', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } } }, '/api/influencers/stats': { get: { - tags: ['Influencers'], summary: 'Get influencer statistics', - description: 'Retrieve aggregated statistics about influencers', + description: 'Returns aggregate statistics about influencers in the system', + tags: ['Influencers'], parameters: [ { name: 'platform', @@ -1524,9 +1708,9 @@ export const openAPISpec = { }, '/api/influencers/{influencer_id}': { get: { - tags: ['Influencers'], summary: 'Get influencer by ID', - description: 'Retrieve detailed information about a specific influencer', + description: 'Returns a specific influencer by ID', + tags: ['Influencers'], parameters: [ { name: 'influencer_id', @@ -1554,6 +1738,170 @@ export const openAPISpec = { description: 'Influencer not found' } } + }, + put: { + summary: 'Update influencer', + description: 'Updates an existing influencer', + tags: ['Influencers'], + parameters: [ + { + name: 'influencer_id', + in: 'path', + description: 'Influencer ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name', 'platform', 'profile_url', 'followers_count', 'video_count'], + properties: { + name: { type: 'string' }, + platform: { type: 'string' }, + profile_url: { type: 'string' }, + followers_count: { type: 'integer' }, + video_count: { type: 'integer' } + } + } + } + } + }, + responses: { + '200': { + description: 'Influencer updated successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string' }, + influencer: { + $ref: '#/components/schemas/Influencer' + } + } + } + } + } + }, + '404': { + description: 'Influencer not found' + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + }, + delete: { + summary: 'Delete influencer', + description: 'Removes an influencer from the system', + tags: ['Influencers'], + parameters: [ + { + name: 'influencer_id', + in: 'path', + description: 'Influencer ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + '200': { + description: 'Influencer deleted successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string' } + } + } + } + } + }, + '404': { + description: 'Influencer not found' + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + } + }, + '/api/influencers/{influencer_id}/posts': { + get: { + summary: 'Get posts by influencer', + description: 'Returns all posts associated with a specific influencer', + tags: ['Influencers', 'Posts'], + parameters: [ + { + name: 'influencer_id', + in: 'path', + description: 'Influencer ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + '200': { + description: 'Posts by influencer', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + posts: { + type: 'array', + items: { + $ref: '#/components/schemas/Post' + } + }, + total: { type: 'integer' } + } + } + } + } + }, + '404': { + description: 'Influencer not found' + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } } }, '/api/analytics/influencer/track': { @@ -2556,6 +2904,526 @@ export const openAPISpec = { } } }, + '/api/projects': { + get: { + summary: 'Get all projects', + description: 'Returns a list of projects with pagination and filtering options', + tags: ['Projects'], + parameters: [ + { + name: 'created_by', + in: 'query', + description: 'Filter by creator user ID', + required: false, + schema: { + type: 'string', + format: 'uuid' + } + }, + { + name: 'limit', + in: 'query', + description: 'Number of projects to return', + required: false, + schema: { + type: 'integer', + default: 20 + } + }, + { + name: 'offset', + in: 'query', + description: 'Number of projects to skip', + required: false, + schema: { + type: 'integer', + default: 0 + } + } + ], + responses: { + '200': { + description: 'List of projects', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + projects: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + description: { type: 'string' }, + created_by: { type: 'string', format: 'uuid' }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' } + } + } + }, + count: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + }, + post: { + summary: 'Create a new project', + description: 'Creates a new project in the system', + tags: ['Projects'], + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + description: { type: 'string' } + } + } + } + } + }, + responses: { + '201': { + description: 'Project created successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + description: { type: 'string' }, + created_by: { type: 'string', format: 'uuid' }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' } + } + } + } + } + }, + '400': { + description: 'Bad request', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + } + }, + '/api/projects/{id}': { + get: { + summary: 'Get project by ID', + description: 'Returns a specific project by ID', + tags: ['Projects'], + parameters: [ + { + name: 'id', + in: 'path', + description: 'Project ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + '200': { + description: 'Project details', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + description: { type: 'string' }, + created_by: { type: 'string', format: 'uuid' }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' } + } + } + } + } + }, + '404': { + description: 'Project not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + }, + put: { + summary: 'Update project', + description: 'Updates an existing project', + tags: ['Projects'], + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + description: 'Project ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' } + } + } + } + } + }, + responses: { + '200': { + description: 'Project updated successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + description: { type: 'string' }, + created_by: { type: 'string', format: 'uuid' }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' } + } + } + } + } + }, + '404': { + description: 'Project not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + }, + delete: { + summary: 'Delete project', + description: 'Removes a project from the system', + tags: ['Projects'], + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + description: 'Project ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + '204': { + description: 'Project deleted successfully' + }, + '404': { + description: 'Project not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + } + }, + '/api/projects/{id}/influencers': { + get: { + summary: 'Get project influencers', + description: 'Returns all influencers associated with a specific project', + tags: ['Projects', 'Influencers'], + parameters: [ + { + name: 'id', + in: 'path', + description: 'Project ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + '200': { + description: 'List of influencers in the project', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + influencers: { + type: 'array', + items: { + $ref: '#/components/schemas/Influencer' + } + }, + count: { type: 'integer' } + } + } + } + } + }, + '404': { + description: 'Project not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + }, + post: { + summary: 'Add influencer to project', + description: 'Adds an influencer to a project', + tags: ['Projects', 'Influencers'], + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + description: 'Project ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['influencer_id'], + properties: { + influencer_id: { type: 'string', format: 'uuid' } + } + } + } + } + }, + responses: { + '201': { + description: 'Influencer added to project successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + project_id: { type: 'string', format: 'uuid' }, + influencer_id: { type: 'string', format: 'uuid' }, + created_at: { type: 'string', format: 'date-time' } + } + } + } + } + }, + '400': { + description: 'Bad request', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '404': { + description: 'Project or influencer not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '409': { + description: 'Influencer already in project', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + } + }, + '/api/projects/{id}/influencers/{influencer_id}': { + delete: { + summary: 'Remove influencer from project', + description: 'Removes an influencer from a project', + tags: ['Projects', 'Influencers'], + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + description: 'Project ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + }, + { + name: 'influencer_id', + in: 'path', + description: 'Influencer ID', + required: true, + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + '204': { + description: 'Influencer removed from project successfully' + }, + '404': { + description: 'Project, influencer, or relationship not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + } + }, }, components: { schemas: { @@ -2825,7 +3693,7 @@ export const openAPISpec = { export const createSwaggerUI = () => { const app = new Hono() - // 设置 Swagger UI 路由 + // 设置 Swagger UI 路由 - simplified configuration to fix white screen app.get('/swagger', swaggerUI({ url: '/api/swagger.json', }))