diff --git a/backend/package.json b/backend/package.json index 74df5ac..4c31d9b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -78,4 +78,4 @@ "tar": "^7.5.3", "diff": "^8.0.3" } -} +} \ No newline at end of file diff --git a/backend/src/database/migrations/012_add_social_interactions.ts b/backend/src/database/migrations/012_add_social_interactions.ts new file mode 100644 index 0000000..14610b3 --- /dev/null +++ b/backend/src/database/migrations/012_add_social_interactions.ts @@ -0,0 +1,36 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + // Table for post likes + await knex.schema.createTable('post_likes', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').references('id').inTable('users').onDelete('CASCADE').notNullable().index(); + table.uuid('post_id').references('id').inTable('posts').onDelete('CASCADE').notNullable().index(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.unique(['user_id', 'post_id']); + }); + + // Table for post comments + await knex.schema.createTable('post_comments', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').references('id').inTable('users').onDelete('CASCADE').notNullable().index(); + table.uuid('post_id').references('id').inTable('posts').onDelete('CASCADE').notNullable().index(); + table.text('text').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + }); + + // Table for post reposts (shares) + await knex.schema.createTable('post_reposts', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').references('id').inTable('users').onDelete('CASCADE').notNullable().index(); + table.uuid('post_id').references('id').inTable('posts').onDelete('CASCADE').notNullable().index(); + table.text('text').nullable(); // Optional commentary on the repost + table.timestamp('created_at').defaultTo(knex.fn.now()); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('post_reposts'); + await knex.schema.dropTableIfExists('post_comments'); + await knex.schema.dropTableIfExists('post_likes'); +} diff --git a/backend/src/routes/music.ts b/backend/src/routes/music.ts index a9463ad..308c0e1 100644 --- a/backend/src/routes/music.ts +++ b/backend/src/routes/music.ts @@ -31,13 +31,51 @@ router.get( const queryParams: any[] = []; let paramCount = 1; - if (filter === 'forYou') { - + if (filter === 'forYou' && req.userId) { + query = ` + WITH user_follows AS ( + SELECT friend_id FROM friendships WHERE user_id = $1 AND status = 'accepted' + ), + user_top_genres AS ( + SELECT DISTINCT jsonb_array_elements_text(mi.metadata->'genres') as genre + FROM ratings r + JOIN music_items mi ON r.music_item_id = mi.id + WHERE r.user_id = $1 AND r.rating >= 8 + ) + SELECT + mi.*, + COALESCE(AVG(r.rating), 0) as rating, + COUNT(r.id) as rating_count, + COUNT(CASE WHEN r.created_at > NOW() - INTERVAL '7 days' THEN 1 END) as recent_ratings, + ( + SELECT COUNT(*) FROM ratings r2 + WHERE r2.music_item_id = mi.id + AND r2.user_id IN (SELECT friend_id FROM user_follows) + ) as friend_rating_count + FROM music_items mi + LEFT JOIN ratings r ON mi.id = r.music_item_id + WHERE 1=1 + AND ( + EXISTS ( + SELECT 1 FROM user_follows uf + JOIN ratings r3 ON r3.user_id = uf.friend_id + WHERE r3.music_item_id = mi.id + ) + OR EXISTS ( + SELECT 1 FROM user_top_genres utg + WHERE mi.metadata->'genres' ? utg.genre + ) + ) + `; + queryParams.push(req.userId); + paramCount++; } query += ` GROUP BY mi.id ORDER BY `; - if (filter === 'trending') { + if (filter === 'forYou') { + query += `friend_rating_count DESC, rating DESC, recent_ratings DESC, mi.created_at DESC`; + } else if (filter === 'trending') { query += `recent_ratings DESC NULLS LAST, rating DESC, rating_count DESC, mi.created_at DESC`; } else { query += `rating DESC, rating_count DESC, mi.created_at DESC`; diff --git a/backend/src/routes/posts.ts b/backend/src/routes/posts.ts index 5971dc8..465f977 100644 --- a/backend/src/routes/posts.ts +++ b/backend/src/routes/posts.ts @@ -128,7 +128,7 @@ router.post( ); let musicItemId: string; - + if (musicItemResult.rows.length > 0) { musicItemId = musicItemResult.rows[0].id; } else { @@ -295,4 +295,257 @@ router.get( } ); +router.post( + '/:id/like', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { id } = req.params; + + const postResult = await pool.query( + 'SELECT user_id FROM posts WHERE id = $1', + [id] + ); + + if (postResult.rows.length === 0) { + throw new CustomError('Post not found', 404); + } + + try { + await pool.query( + 'INSERT INTO post_likes (user_id, post_id) VALUES ($1, $2)', + [req.userId, id] + ); + + if (postResult.rows[0].user_id !== req.userId) { + const userResult = await pool.query('SELECT username FROM users WHERE id = $1', [req.userId]); + const username = userResult.rows[0]?.username || 'Someone'; + + await pool.query( + `INSERT INTO notifications (user_id, type, title, message, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [ + postResult.rows[0].user_id, + 'post_like', + 'New Like', + `${username} liked your post`, + JSON.stringify({ postId: id, likedBy: req.userId }) + ] + ); + } + } catch (err: any) { + if (err.code === '23505') { + // Unique violation (already liked) + res.json({ success: true, message: 'Post already liked' }); + return; + } + throw err; + } + + res.json({ + success: true, + message: 'Post liked successfully' + }); + } catch (error) { + next(error); + } + } +); + +router.delete( + '/:id/like', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { id } = req.params; + + const result = await pool.query( + 'DELETE FROM post_likes WHERE user_id = $1 AND post_id = $2 RETURNING *', + [req.userId, id] + ); + + if (result.rows.length === 0) { + throw new CustomError('Post not liked', 404); + } + + res.json({ + success: true, + message: 'Post unliked successfully' + }); + } catch (error) { + next(error); + } + } +); + +router.get( + '/:id/comments', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { id } = req.params; + + const result = await pool.query( + `SELECT pc.*, u.username + FROM post_comments pc + JOIN users u ON pc.user_id = u.id + WHERE pc.post_id = $1 + ORDER BY pc.created_at ASC`, + [id] + ); + + const comments = result.rows.map((row: any) => ({ + id: row.id, + userId: row.user_id, + username: row.username, + text: row.text, + createdAt: row.created_at + })); + + res.json({ + success: true, + data: comments + }); + } catch (error) { + next(error); + } + } +); + +router.post( + '/:id/comment', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { id } = req.params; + const { text } = req.body; + + if (!text || typeof text !== 'string' || text.trim().length === 0) { + throw new CustomError('Comment text is required', 400); + } + + const postResult = await pool.query( + 'SELECT user_id FROM posts WHERE id = $1', + [id] + ); + + if (postResult.rows.length === 0) { + throw new CustomError('Post not found', 404); + } + + const commentResult = await pool.query( + `INSERT INTO post_comments (user_id, post_id, text) + VALUES ($1, $2, $3) + RETURNING *`, + [req.userId, id, text.trim()] + ); + + if (postResult.rows[0].user_id !== req.userId) { + const userResult = await pool.query('SELECT username FROM users WHERE id = $1', [req.userId]); + const username = userResult.rows[0]?.username || 'Someone'; + + await pool.query( + `INSERT INTO notifications (user_id, type, title, message, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [ + postResult.rows[0].user_id, + 'post_comment', + 'New Comment', + `${username} commented on your post`, + JSON.stringify({ postId: id, commentId: commentResult.rows[0].id, commentedBy: req.userId }) + ] + ); + } + + res.json({ + success: true, + data: { + id: commentResult.rows[0].id, + text: commentResult.rows[0].text, + createdAt: commentResult.rows[0].created_at + }, + message: 'Comment added successfully' + }); + } catch (error) { + next(error); + } + } +); + +router.post( + '/:id/share', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { id } = req.params; + const { text } = req.body; + + const postResult = await pool.query( + 'SELECT user_id FROM posts WHERE id = $1', + [id] + ); + + if (postResult.rows.length === 0) { + throw new CustomError('Post not found', 404); + } + + const repostResult = await pool.query( + `INSERT INTO post_reposts (user_id, post_id, text) + VALUES ($1, $2, $3) + RETURNING *`, + [req.userId, id, text || null] + ); + + if (postResult.rows[0].user_id !== req.userId) { + const userResult = await pool.query('SELECT username FROM users WHERE id = $1', [req.userId]); + const username = userResult.rows[0]?.username || 'Someone'; + + await pool.query( + `INSERT INTO notifications (user_id, type, title, message, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [ + postResult.rows[0].user_id, + 'post_repost', + 'New Repost', + `${username} reposted your post`, + JSON.stringify({ postId: id, repostId: repostResult.rows[0].id, repostedBy: req.userId }) + ] + ); + } + + res.json({ + success: true, + data: { + id: repostResult.rows[0].id, + text: repostResult.rows[0].text, + createdAt: repostResult.rows[0].created_at + }, + message: 'Post shared successfully' + }); + } catch (error) { + next(error); + } + } +); + export default router; diff --git a/backend/src/routes/profile.ts b/backend/src/routes/profile.ts index ec77748..d1f1b46 100644 --- a/backend/src/routes/profile.ts +++ b/backend/src/routes/profile.ts @@ -109,7 +109,7 @@ router.get( const decadePreference: Record = {}; const decadeCounts: Record = {}; - + decadeResult.rows.forEach((row: any) => { const releaseDate = row.release_date; if (releaseDate && typeof releaseDate === 'string') { @@ -178,7 +178,7 @@ router.get( genreAffinity, decadePreference, attributes, - controversyAffinity + controversyAffinity } }); } catch (error) { @@ -263,5 +263,39 @@ router.put( } ); +router.get( + '/search', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const query = req.query.q as string; + if (!query || query.length < 2) { + throw new CustomError('Search query must be at least 2 characters', 400); + } + + const result = await pool.query( + `SELECT id, username, email + FROM users + WHERE username ILIKE $1 + AND id != $2 + AND deleted_at IS NULL + LIMIT 20`, + [`%${query}%`, req.userId] + ); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + next(error); + } + } +); + export default router; diff --git a/backend/src/routes/social.ts b/backend/src/routes/social.ts index 85181be..439392f 100644 --- a/backend/src/routes/social.ts +++ b/backend/src/routes/social.ts @@ -146,6 +146,36 @@ router.post( } ); +router.delete( + '/unfollow/:userId', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { userId } = req.params; + + const result = await pool.query( + 'DELETE FROM friendships WHERE user_id = $1 AND friend_id = $2 RETURNING *', + [req.userId, userId] + ); + + if (result.rows.length === 0) { + throw new CustomError('Not following this user', 404); + } + + res.json({ + success: true, + message: 'Unfollowed successfully' + }); + } catch (error) { + next(error); + } + } +); + router.get( '/compatibility/:userId', authMiddleware, @@ -253,4 +283,60 @@ router.get( } ); +router.get( + '/following', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const result = await pool.query( + `SELECT u.id, u.username, u.email, f.status, f.created_at + FROM friendships f + JOIN users u ON f.friend_id = u.id + WHERE f.user_id = $1 AND u.deleted_at IS NULL + ORDER BY f.created_at DESC`, + [req.userId] + ); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + next(error); + } + } +); + +router.get( + '/followers', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const result = await pool.query( + `SELECT u.id, u.username, u.email, f.status, f.created_at + FROM friendships f + JOIN users u ON f.user_id = u.id + WHERE f.friend_id = $1 AND u.deleted_at IS NULL + ORDER BY f.created_at DESC`, + [req.userId] + ); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + next(error); + } + } +); + export default router; diff --git a/frontend/MusicApp/Services/PostService.swift b/frontend/MusicApp/Services/PostService.swift index c32f9e9..c770648 100644 --- a/frontend/MusicApp/Services/PostService.swift +++ b/frontend/MusicApp/Services/PostService.swift @@ -64,6 +64,72 @@ class PostService { nextPage: data.pagination?.nextPage ) } + + func likePost(postId: String) async throws { + _ = try await apiService.request( + endpoint: "/posts/\(postId)/like", + method: .post, + requiresAuth: true + ) as APIResponse + } + + func unlikePost(postId: String) async throws { + _ = try await apiService.request( + endpoint: "/posts/\(postId)/like", + method: .delete, + requiresAuth: true + ) as APIResponse + } + + func getComments(postId: String) async throws -> [Comment] { + let response: APIResponse<[Comment]> = try await apiService.request( + endpoint: "/posts/\(postId)/comments", + method: .get, + requiresAuth: true + ) + return response.data ?? [] + } + + func addComment(postId: String, text: String) async throws -> Comment { + let request = CommentRequest(text: text) + let response: APIResponse = try await apiService.request( + endpoint: "/posts/\(postId)/comment", + method: .post, + body: request, + requiresAuth: true + ) + + guard let data = response.data else { + throw NetworkError.unknown(NSError(domain: "PostService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to add comment"])) + } + return data + } + + func sharePost(postId: String, text: String?) async throws { + let request = ShareRequest(text: text) + _ = try await apiService.request( + endpoint: "/posts/\(postId)/share", + method: .post, + body: request, + requiresAuth: true + ) as APIResponse + } +} + +struct CommentRequest: Codable { + let text: String +} + +struct ShareRequest: Codable { + let text: String? +} + +struct Comment: Codable, Identifiable { + let id: String + let userId: String + let username: String + let text: String + let createdAt: String } struct PostResponse: Codable { diff --git a/frontend/MusicApp/Services/ProfileService.swift b/frontend/MusicApp/Services/ProfileService.swift index 05a5f33..d0811bf 100644 --- a/frontend/MusicApp/Services/ProfileService.swift +++ b/frontend/MusicApp/Services/ProfileService.swift @@ -42,4 +42,18 @@ class ProfileService { return data } + + func searchUsers(query: String) async throws -> [UserSummary] { + let response: APIResponse<[UserSummary]> = try await apiService.request( + endpoint: "/profile/search?q=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", + method: .get + ) + return response.data ?? [] + } +} + +struct UserSummary: Codable, Identifiable { + let id: String + let username: String + let email: String } diff --git a/frontend/MusicApp/Services/SocialService.swift b/frontend/MusicApp/Services/SocialService.swift index 9ef267a..279a628 100644 --- a/frontend/MusicApp/Services/SocialService.swift +++ b/frontend/MusicApp/Services/SocialService.swift @@ -25,6 +25,32 @@ class SocialService { ) as APIResponse } + func unfollow(userId: String) async throws { + _ = try await apiService.request( + endpoint: "/social/unfollow/\(userId)", + method: .delete, + requiresAuth: true + ) as APIResponse + } + + func getFollowing() async throws -> [SocialUser] { + let response: APIResponse<[SocialUser]> = try await apiService.request( + endpoint: "/social/following", + method: .get, + requiresAuth: true + ) + return response.data ?? [] + } + + func getFollowers() async throws -> [SocialUser] { + let response: APIResponse<[SocialUser]> = try await apiService.request( + endpoint: "/social/followers", + method: .get, + requiresAuth: true + ) + return response.data ?? [] + } + func getCompatibility(userId: String) async throws -> Int { let response: APIResponse = try await apiService.request( endpoint: "/social/compatibility/\(userId)", @@ -61,3 +87,11 @@ struct TasteComparison: Codable { let sharedArtists: Int let sharedGenres: [String] } + +struct SocialUser: Codable, Identifiable { + let id: String + let username: String + let email: String + let status: String + let createdAt: String +} diff --git a/webapp/app/page.tsx b/webapp/app/page.tsx index 0185a3c..5228328 100644 --- a/webapp/app/page.tsx +++ b/webapp/app/page.tsx @@ -30,14 +30,6 @@ export default function Home() {

Please use the MusIQ iOS app to access your dashboard, rate music, and see global rankings.

-
- - Account Support - -