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
20 changes: 20 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
setupFiles: ['dotenv/config'],
setupFilesAfterEnv: [],
testMatch: ['**/tests/**/*.test.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100
}
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
}
};
7,609 changes: 5,453 additions & 2,156 deletions backend/package-lock.json

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"seed": "knex seed:run",
"lint": "eslint src --ext .ts",
"format": "prettier --write \"src/**/*.ts\"",
"delete-user": "ts-node src/scripts/delete-supabase-user.ts"
"delete-user": "ts-node src/scripts/delete-supabase-user.ts",
"test": "jest",
"test:coverage": "jest --coverage"
},
"keywords": [
"music",
Expand Down Expand Up @@ -72,7 +74,12 @@
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"eslint": "^8.56.0",
"prettier": "^3.1.1"
"prettier": "3.1.1",
"jest": "29.7.0",
"ts-jest": "29.1.1",
"@types/jest": "29.5.11",
"supertest": "6.3.3",
"@types/supertest": "2.0.16"
},
"overrides": {
"tar": "^7.5.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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();
Expand All @@ -10,7 +10,7 @@ export async function up(knex: Knex): Promise<void> {
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();
Expand All @@ -19,12 +19,12 @@ export async function up(knex: Knex): Promise<void> {
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.text('text').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
});
}
Expand Down
30 changes: 16 additions & 14 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@

const getAllowedOrigins = (): string[] | boolean => {
const corsOrigin = process.env.CORS_ORIGIN;

if (!corsOrigin || corsOrigin === '*') {
if (process.env.NODE_ENV === 'production') {
logger.warn('CORS_ORIGIN is not set or set to * in production. This is insecure.');
return false;
}
return true;
}

return corsOrigin.split(',').map(origin => origin.trim()).filter(Boolean);
};

Expand All @@ -50,7 +50,7 @@
verify: (req: express.Request, _res: express.Response, buf: Buffer) => {
const url = req.url || req.originalUrl || req.path;
if (url.includes('webhooks') || url.includes('interactions')) {
(req as any).rawBody = buf;

Check warning on line 53 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / CI - Lint and Build

Unexpected any. Specify a different type
}
}
}));
Expand Down Expand Up @@ -95,17 +95,19 @@

import { testConnection } from './database/connection';

app.listen(PORT, async () => {
logger.info('Service: musiq-api');
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);

const dbConnected = await testConnection();
if (dbConnected) {
logger.info('Database connection established');
} else {
logger.error('Database connection failed - check DATABASE_URL');
}
});
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, async () => {
logger.info('Service: musiq-api');
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);

const dbConnected = await testConnection();
if (dbConnected) {
logger.info('Database connection established');
} else {
logger.error('Database connection failed - check DATABASE_URL');
}
});
}

export default app;
2 changes: 1 addition & 1 deletion backend/src/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const authMiddleware = async (

const user = userResult.rows[0];

// Sync email_verified status if Supabase says it's verified but local DB says it's not

if (supabaseUser.emailVerified && !user.email_verified) {
await pool.query(
'UPDATE users SET email_verified = true, updated_at = NOW() WHERE id = $1',
Expand Down
8 changes: 4 additions & 4 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@
authMiddleware,
async (_req, res, next) => {
try {
// In a Supabase-only auth system, the client handles logging out.
// This endpoint can be used for custom server-side session invalidation if needed.


await authService.logout();
res.json({
success: true,
Expand Down Expand Up @@ -141,7 +141,7 @@
return;
}

const { password_hash, ...userData } = user;

Check failure on line 144 in backend/src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / CI - Lint and Build

'password_hash' is assigned a value but never used

res.json({
success: true,
Expand Down Expand Up @@ -255,7 +255,7 @@
return;
}

// Get the auth ID from the user

const user = await authService.getUserById(req.userId);
if (!user || !user.supabase_auth_id) {
res.status(404).json({
Expand All @@ -268,7 +268,7 @@
return;
}

// Update password using admin client

await supabaseService.updateUserPassword(user.supabase_auth_id, newPassword);

res.json({
Expand Down
81 changes: 41 additions & 40 deletions backend/src/routes/music.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,26 @@ router.get(
);

router.get(
'/:id',
'/search',
searchLimiter,
authMiddleware,
async (req, res, next) => {
try {
const { id } = req.params;
const rawQuery = req.query.q;
const query =
typeof rawQuery === 'string'
? rawQuery
: Array.isArray(rawQuery)
? (rawQuery[0] ?? '')
: '';

if (!query || (typeof query === 'string' && query.length < 2)) {
res.json({
success: true,
data: []
});
return;
}

const result = await pool.query(
`SELECT
Expand All @@ -142,17 +157,16 @@ router.get(
COUNT(r.id) as rating_count
FROM music_items mi
LEFT JOIN ratings r ON mi.id = r.music_item_id
WHERE mi.id = $1
GROUP BY mi.id`,
[id]
WHERE
mi.title ILIKE $1 OR
mi.artist ILIKE $1
GROUP BY mi.id
ORDER BY rating DESC
LIMIT 20`,
[`%${query}%`]
);

if (result.rows.length === 0) {
throw new CustomError('Music item not found', 404);
}

const row = result.rows[0];
const item = {
const items = result.rows.map((row: any) => ({
id: row.id,
type: row.type,
title: row.title,
Expand All @@ -163,11 +177,11 @@ router.get(
spotifyId: row.spotify_id,
appleMusicId: row.apple_music_id,
metadata: row.metadata
};
}));

res.json({
success: true,
data: item
data: items
});
} catch (error) {
next(error);
Expand All @@ -176,26 +190,11 @@ router.get(
);

router.get(
'/search',
searchLimiter,
'/:id',
authMiddleware,
async (req, res, next) => {
try {
const rawQuery = req.query.q;
const query =
typeof rawQuery === 'string'
? rawQuery
: Array.isArray(rawQuery)
? (rawQuery[0] ?? '')
: '';

if (!query || (typeof query === 'string' && query.length < 2)) {
res.json({
success: true,
data: []
});
return;
}
const { id } = req.params;

const result = await pool.query(
`SELECT
Expand All @@ -204,16 +203,17 @@ router.get(
COUNT(r.id) as rating_count
FROM music_items mi
LEFT JOIN ratings r ON mi.id = r.music_item_id
WHERE
mi.title ILIKE $1 OR
mi.artist ILIKE $1
GROUP BY mi.id
ORDER BY rating DESC
LIMIT 20`,
[`%${query}%`]
WHERE mi.id = $1
GROUP BY mi.id`,
[id]
);

const items = result.rows.map((row: any) => ({
if (result.rows.length === 0) {
throw new CustomError('Music item not found', 404);
}

const row = result.rows[0];
const item = {
id: row.id,
type: row.type,
title: row.title,
Expand All @@ -224,16 +224,17 @@ router.get(
spotifyId: row.spotify_id,
appleMusicId: row.apple_music_id,
metadata: row.metadata
}));
};

res.json({
success: true,
data: items
data: item
});
} catch (error) {
next(error);
}
}
);


export default router;
2 changes: 1 addition & 1 deletion backend/src/routes/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@

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]
);
Expand Down Expand Up @@ -339,7 +339,7 @@
}
} catch (err: any) {
if (err.code === '23505') {
// Unique violation (already liked)

res.json({ success: true, message: 'Post already liked' });
return;
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ const handleDiscordInteraction = async (req: DiscordRequest, res: Response) => {
data: error.response?.data
});
}
await sendDiscordFollowup(applicationId, interactionToken, ' Failed to create GitHub issue. Check server logs.');
await sendDiscordFollowup(applicationId, interactionToken, ' Failed to create GitHub issue. Check server logs.');
}
})();

Expand Down
12 changes: 6 additions & 6 deletions backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export class AuthService {
emailVerified: !!supabaseSession.user.email_confirmed_at
};
} else {
// Fallback for legacy users if they exist, though we are moving to Supabase only

throw new CustomError('Legacy user authentication not supported.', 400);
}
} catch (error) {
Expand Down Expand Up @@ -148,9 +148,9 @@ export class AuthService {

async refreshToken(_refreshToken: string): Promise<AuthTokens> {
try {
// Supabase handles refresh tokens automatically on the client-side
// The backend will mostly just validate the session token.
// If a new session token is needed, the client should use Supabase client's refresh mechanism.



throw new CustomError('Refresh token handled by Supabase client', 400);
} catch (error) {
if (error instanceof CustomError) {
Expand All @@ -162,8 +162,8 @@ export class AuthService {
}

async logout(): Promise<void> {
// Supabase handles logout on the client side, revoking the session
// This backend logout might be used for specific server-side session invalidation if custom sessions are implemented


logger.info('User logout initiated (backend only, client handles Supabase session)');
}

Expand Down
Loading
Loading