Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,4 @@
"tar": "^7.5.3",
"diff": "^8.0.3"
}
}
}
36 changes: 36 additions & 0 deletions backend/src/database/migrations/012_add_social_interactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
// 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<void> {
await knex.schema.dropTableIfExists('post_reposts');
await knex.schema.dropTableIfExists('post_comments');
await knex.schema.dropTableIfExists('post_likes');
}
44 changes: 41 additions & 3 deletions backend/src/routes/music.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
255 changes: 254 additions & 1 deletion backend/src/routes/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,13 @@

const { name, category, rating, text } = req.body;

let musicItemResult = await pool.query(

Check failure on line 125 in backend/src/routes/posts.ts

View workflow job for this annotation

GitHub Actions / CI - Lint and Build

'musicItemResult' is never reassigned. Use 'const' instead
'SELECT id FROM music_items WHERE LOWER(title) = LOWER($1) AND type = $2',
[name.trim(), category]
);

let musicItemId: string;

if (musicItemResult.rows.length > 0) {
musicItemId = musicItemResult.rows[0].id;
} else {
Expand Down Expand Up @@ -295,4 +295,257 @@
}
);

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;
Loading
Loading