diff --git a/backend/package-lock.json b/backend/package-lock.json index 17b70d3..1891eae 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@azure/identity": "^4.0.1", "@azure/keyvault-secrets": "^4.7.0", + "@supabase/supabase-js": "^2.90.1", "axios": "^1.13.2", "bcrypt": "^5.1.1", "connect-redis": "^7.1.0", @@ -698,6 +699,86 @@ "text-hex": "1.0.x" } }, + "node_modules/@supabase/auth-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz", + "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz", + "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz", + "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz", + "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz", + "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", + "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.90.1", + "@supabase/functions-js": "2.90.1", + "@supabase/postgrest-js": "2.90.1", + "@supabase/realtime-js": "2.90.1", + "@supabase/storage-js": "2.90.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -931,6 +1012,12 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -1010,6 +1097,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -3129,6 +3225,15 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5652,6 +5757,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3e424d5..74df5ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,8 @@ "migrate:rollback": "knex migrate:rollback", "seed": "knex seed:run", "lint": "eslint src --ext .ts", - "format": "prettier --write \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.ts\"", + "delete-user": "ts-node src/scripts/delete-supabase-user.ts" }, "keywords": [ "music", @@ -24,6 +25,7 @@ "dependencies": { "@azure/identity": "^4.0.1", "@azure/keyvault-secrets": "^4.7.0", + "@supabase/supabase-js": "^2.90.1", "axios": "^1.13.2", "bcrypt": "^5.1.1", "connect-redis": "^7.1.0", diff --git a/backend/src/database/connection.ts b/backend/src/database/connection.ts index ea3654c..0600bd3 100644 --- a/backend/src/database/connection.ts +++ b/backend/src/database/connection.ts @@ -22,7 +22,7 @@ export const getDatabasePool = (): Pool => { max: parseInt(process.env.DB_POOL_MAX || '10'), min: parseInt(process.env.DB_POOL_MIN || '2'), idleTimeoutMillis: 30000, - connectionTimeoutMillis: 10000, + connectionTimeoutMillis: 30000, allowExitOnIdle: false, }; diff --git a/backend/src/database/migrations/010_create_posts.ts b/backend/src/database/migrations/010_create_posts.ts new file mode 100644 index 0000000..332a8f2 --- /dev/null +++ b/backend/src/database/migrations/010_create_posts.ts @@ -0,0 +1,18 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('posts', (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('music_item_id').references('id').inTable('music_items').onDelete('CASCADE').notNullable().index(); + table.integer('rating').notNullable().checkBetween([1, 10]); + table.text('text').nullable(); + table.timestamps(true, true); + }); + + await knex.schema.raw('CREATE INDEX idx_posts_created_at ON posts(created_at DESC)'); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('posts'); +} diff --git a/backend/src/database/migrations/011_add_user_names.ts b/backend/src/database/migrations/011_add_user_names.ts new file mode 100644 index 0000000..afe8cd1 --- /dev/null +++ b/backend/src/database/migrations/011_add_user_names.ts @@ -0,0 +1,19 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('users', (table) => { + table.string('first_name').nullable(); + table.string('last_name').nullable(); + table.uuid('supabase_auth_id').nullable(); + }); + + await knex.schema.raw('CREATE INDEX idx_users_supabase_auth_id ON users(supabase_auth_id)'); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('users', (table) => { + table.dropColumn('first_name'); + table.dropColumn('last_name'); + table.dropColumn('supabase_auth_id'); + }); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 255b9db..72c8a48 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import { securityMiddleware } from './middleware/security.middleware'; import { rateLimitMiddleware } from './middleware/rate-limit.middleware'; import { logger } from './config/logger'; + dotenv.config(); const app = express(); @@ -61,8 +62,11 @@ app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +app.get('/', (_req, res) => { + res.send('

Welcome to MusIQ API

'); +}); + import authRoutes from './routes/auth'; -import oauthRoutes from './routes/oauth'; import profileRoutes from './routes/profile'; import musicRoutes from './routes/music'; import ratingRoutes from './routes/ratings'; @@ -71,9 +75,10 @@ import socialRoutes from './routes/social'; import notificationRoutes from './routes/notifications'; import adminRoutes from './routes/admin'; import webhookRoutes from './routes/webhooks'; +import postRoutes from './routes/posts'; app.use('/api/auth', authRoutes); -app.use('/api/auth/oauth', oauthRoutes); + app.use('/api/profile', profileRoutes); app.use('/api/music', musicRoutes); app.use('/api/ratings', ratingRoutes); @@ -82,6 +87,7 @@ app.use('/api/social', socialRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/admin', adminRoutes); app.use('/api/webhooks', webhookRoutes); +app.use('/api/posts', postRoutes); app.use('/interactions', webhookRoutes); @@ -102,4 +108,4 @@ app.listen(PORT, async () => { } }); -export default app; +export default app; \ No newline at end of file diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index a8bd644..03ca70d 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from 'express'; -import jwt from 'jsonwebtoken'; import { CustomError } from './error.middleware'; import { getDatabasePool } from '../database/connection'; +import { supabaseService } from '../services/supabase.service'; export interface AuthRequest extends Request { userId?: string; @@ -22,19 +22,17 @@ export const authMiddleware = async ( } const token = authHeader.substring(7); - const jwtSecret = process.env.JWT_SECRET || 'default-secret-change-in-production'; + const pool = getDatabasePool(); + + const supabaseUser = await supabaseService.verifyAccessToken(token); - let decoded: any; - try { - decoded = jwt.verify(token, jwtSecret); - } catch (error) { + if (!supabaseUser) { throw new CustomError('Invalid or expired token', 401); } - const pool = getDatabasePool(); const userResult = await pool.query( - 'SELECT id, email, role FROM users WHERE id = $1 AND deleted_at IS NULL', - [decoded.userId] + 'SELECT id, email, role, email_verified FROM users WHERE supabase_auth_id = $1 AND deleted_at IS NULL', + [supabaseUser.userId] ); if (userResult.rows.length === 0) { @@ -43,6 +41,14 @@ 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', + [user.id] + ); + } + req.userId = user.id; req.userEmail = user.email; req.userRole = user.role; diff --git a/backend/src/middleware/validation.middleware.ts b/backend/src/middleware/validation.middleware.ts index 1392f45..018a498 100644 --- a/backend/src/middleware/validation.middleware.ts +++ b/backend/src/middleware/validation.middleware.ts @@ -1,6 +1,7 @@ import { body, validationResult, ValidationChain } from 'express-validator'; import { Request, Response, NextFunction } from 'express'; import { CustomError } from './error.middleware'; +import { getDatabasePool } from '../database/connection'; export const validate = (validations: ValidationChain[]) => { return async (req: Request, _res: Response, next: NextFunction): Promise => { @@ -21,6 +22,35 @@ export const validate = (validations: ValidationChain[]) => { }; export const signupValidation = [ + body('email') + .trim() + .notEmpty() + .withMessage('Email is required') + .isEmail() + .withMessage('Valid email address is required') + .normalizeEmail() + .toLowerCase() + .custom(async (value) => { + const pool = getDatabasePool(); + const result = await pool.query('SELECT id FROM users WHERE email = $1', [value]); + if (result.rows.length > 0) { + throw new Error('This email is already registered'); + } + return true; + }) + .withMessage('This email is already registered'), + body('firstName') + .trim() + .notEmpty() + .withMessage('First name is required') + .isLength({ min: 1, max: 50 }) + .withMessage('First name must be between 1 and 50 characters'), + body('lastName') + .trim() + .notEmpty() + .withMessage('Last name is required') + .isLength({ min: 1, max: 50 }) + .withMessage('Last name must be between 1 and 50 characters'), body('username') .trim() .isLength({ min: 3, max: 30 }) @@ -31,24 +61,18 @@ export const signupValidation = [ .isLength({ min: 8, max: 128 }) .withMessage('Password must be between 8 and 128 characters') .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/) - .withMessage('Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), - body('confirmPassword') - .custom((value, { req }) => { - if (value !== req.body.password) { - throw new Error('Passwords do not match'); - } - return true; - }) - .withMessage('Passwords must match') + .withMessage('Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)') ]; export const loginValidation = [ - body('username') + body('email') .trim() .notEmpty() - .withMessage('Username is required') - .isLength({ min: 3, max: 30 }) - .withMessage('Username must be between 3 and 30 characters'), + .withMessage('Email is required') + .isEmail() + .withMessage('Valid email address is required') + .normalizeEmail() + .toLowerCase(), body('password') .notEmpty() .withMessage('Password is required') @@ -70,3 +94,38 @@ export const ratingValidation = [ .isString() .withMessage('Each tag must be a string') ]; + +export const postValidation = [ + body('musicItemId') + .isUUID() + .withMessage('Valid music item ID is required'), + body('rating') + .isInt({ min: 1, max: 10 }) + .withMessage('Rating must be between 1 and 10'), + body('text') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Post text must be 500 characters or less') +]; + +export const createPostWithMusicItemValidation = [ + body('name') + .trim() + .notEmpty() + .withMessage('Music item name is required') + .isLength({ min: 1, max: 200 }) + .withMessage('Music item name must be between 1 and 200 characters'), + body('category') + .isIn(['album', 'song', 'artist']) + .withMessage('Category must be album, song, or artist'), + body('rating') + .isInt({ min: 1, max: 10 }) + .withMessage('Rating must be between 1 and 10'), + body('text') + .optional() + .isString() + .trim() + .isLength({ max: 500 }) + .withMessage('Post text must be 500 characters or less') +]; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index f47dbf4..d9f05a5 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { AuthService } from '../services/auth.service'; +import { supabaseService } from '../services/supabase.service'; import { authLimiter } from '../middleware/rate-limit.middleware'; import { validate, signupValidation, loginValidation } from '../middleware/validation.middleware'; import { authMiddleware, AuthRequest } from '../middleware/auth.middleware'; @@ -13,13 +14,12 @@ router.post( validate(signupValidation), async (req, res, next) => { try { - const { username, password } = req.body; - const tokens = await authService.signup(username, password); - + const { email, username, password, firstName, lastName } = req.body; + await authService.signup(email, username, password, firstName, lastName); + res.json({ success: true, - data: tokens, - message: 'User created successfully' + message: 'User created successfully. Please check your email to verify your account.' }); } catch (error) { next(error); @@ -33,13 +33,9 @@ router.post( validate(loginValidation), async (req, res, next) => { try { - const { username, password } = req.body; - const deviceId = req.headers['x-device-id'] as string; - const ipAddress = req.ip || req.socket.remoteAddress || undefined; - const userAgent = req.headers['user-agent'] || undefined; + const { email, password } = req.body; + const tokens = await authService.login(email, password); - const tokens = await authService.login(username, password, deviceId, ipAddress, userAgent); - res.json({ success: true, data: tokens, @@ -51,6 +47,23 @@ router.post( } ); +router.post( + '/forgot-password', + authLimiter, + async (req, res, next) => { + try { + const { email } = req.body; + await authService.forgotPassword(email); + res.json({ + success: true, + message: 'If an account with that email exists, a password reset link has been sent.' + }); + } catch (error) { + next(error); + } + } +); + router.post( '/refresh', async (req, res, next) => { @@ -69,7 +82,7 @@ router.post( } const tokens = await authService.refreshToken(refreshToken); - + res.json({ success: true, data: tokens, @@ -84,14 +97,11 @@ router.post( router.post( '/logout', authMiddleware, - async (req, res, next) => { + async (_req, res, next) => { try { - const { refreshToken } = req.body; - - if (refreshToken) { - await authService.logout(refreshToken); - } - + // 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, message: 'Logout successful' @@ -143,4 +153,145 @@ router.get( } ); -export default router; +router.post( + '/reset-password', + authLimiter, + async (req, res, next) => { + try { + const { code, newPassword } = req.body; + + if (!code || !newPassword) { + res.status(400).json({ + success: false, + error: { + code: '400', + message: 'Code and new password are required' + } + }); + return; + } + + const { session } = await supabaseService.verifyOtp(code, 'recovery'); + + if (!session) { + res.status(400).json({ + success: false, + error: { + code: '400', + message: 'Invalid or expired reset code' + } + }); + return; + } + + await supabaseService.updatePassword(session.access_token, newPassword); + + res.json({ + success: true, + message: 'Password updated successfully' + }); + } catch (error) { + next(error); + } + } +); + +router.post( + '/verify-email', + authLimiter, + async (req, res, next) => { + try { + const { code } = req.body; + + if (!code) { + res.status(400).json({ + success: false, + error: { + code: '400', + message: 'Verification code is required' + } + }); + return; + } + + const { user } = await supabaseService.verifyOtp(code, 'signup'); + + if (!user) { + res.status(400).json({ + success: false, + error: { + code: '400', + message: 'Invalid or expired verification code' + } + }); + return; + } + + res.json({ + success: true, + message: 'Email verified successfully' + }); + } catch (error) { + next(error); + } + } +); + + + +router.post( + '/update-password', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + const { newPassword } = req.body; + + if (!newPassword) { + res.status(400).json({ + success: false, + error: { + code: '400', + message: 'New password is required' + } + }); + return; + } + + if (!req.userId) { + res.status(401).json({ + success: false, + error: { + code: '401', + message: 'Unauthorized' + } + }); + 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({ + success: false, + error: { + code: '404', + message: 'User not found' + } + }); + return; + } + + // Update password using admin client + await supabaseService.updateUserPassword(user.supabase_auth_id, newPassword); + + res.json({ + success: true, + message: 'Password updated successfully' + }); + } catch (error) { + next(error); + } + } +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts deleted file mode 100644 index 8c34400..0000000 --- a/backend/src/routes/oauth.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Router, Request, Response, NextFunction } from 'express'; -import { authLimiter } from '../middleware/rate-limit.middleware'; -import { IdentityFederationService } from '../security/identity-federation'; -import { logger } from '../config/logger'; -import { CustomError } from '../middleware/error.middleware'; - -const router = Router(); -const identityService = new IdentityFederationService(); - -interface OAuthRequestBody { - token?: string; - idToken?: string; - email?: string; - userId?: string; - userIdentifier?: string; - name?: string; -} - -router.post( - '/apple', - authLimiter, - async (req: Request<{}, {}, OAuthRequestBody>, res: Response, next: NextFunction) => { - try { - const { token, idToken: _idToken } = req.body; - - if (!token) { - throw new CustomError('Authorization code is required', 400); - } - - let email = req.body.email; - let userIdentifier = req.body.userIdentifier; - - if (!email) { - email = `apple_${Date.now()}@privaterelay.appleid.com`; - } - - if (!userIdentifier) { - userIdentifier = `apple_${Date.now()}`; - } - - const oauthUser = { - id: userIdentifier, - email: email, - name: req.body.name, - provider: 'apple' as const - }; - - const { user, tokens } = await identityService.findOrCreateOAuthUser(oauthUser); - - logger.info('Apple Sign In successful', { userId: user.id }); - - res.json({ - success: true, - data: tokens, - message: 'Apple Sign In successful' - }); - } catch (error: unknown) { - next(error); - } - } -); - -router.post( - '/google', - authLimiter, - async (req: Request<{}, {}, OAuthRequestBody>, res: Response, next: NextFunction) => { - try { - const { token, idToken: _idToken } = req.body; - - if (!token) { - throw new CustomError('Authorization code is required', 400); - } - - let email = req.body.email; - let userId = req.body.userId; - - if (!email) { - email = `google_${Date.now()}@gmail.com`; - } - - if (!userId) { - userId = `google_${Date.now()}`; - } - - const oauthUser = { - id: userId, - email: email, - name: req.body.name, - provider: 'google' as const - }; - - const { user, tokens } = await identityService.findOrCreateOAuthUser(oauthUser); - - logger.info('Google Sign In successful', { userId: user.id }); - - res.json({ - success: true, - data: tokens, - message: 'Google Sign In successful' - }); - } catch (error: unknown) { - next(error); - } - } -); - -router.post( - '/spotify', - authLimiter, - async (req: Request<{}, {}, OAuthRequestBody>, res: Response, next: NextFunction) => { - try { - const { token } = req.body; - - if (!token) { - throw new CustomError('Authorization code is required', 400); - } - - let email = req.body.email; - let userId = req.body.userId; - - if (!email) { - email = `spotify_${Date.now()}@spotify.com`; - } - - if (!userId) { - userId = `spotify_${Date.now()}`; - } - - const oauthUser = { - id: userId, - email: email, - name: req.body.name, - provider: 'spotify' as const - }; - - const { user, tokens } = await identityService.findOrCreateOAuthUser(oauthUser); - - logger.info('Spotify OAuth successful', { userId: user.id }); - - res.json({ - success: true, - data: tokens, - message: 'Spotify OAuth successful' - }); - } catch (error: unknown) { - next(error); - } - } -); - -export default router; diff --git a/backend/src/routes/posts.ts b/backend/src/routes/posts.ts new file mode 100644 index 0000000..5971dc8 --- /dev/null +++ b/backend/src/routes/posts.ts @@ -0,0 +1,298 @@ +import { Router } from 'express'; +import { authMiddleware, AuthRequest } from '../middleware/auth.middleware'; +import { ratingLimiter } from '../middleware/rate-limit.middleware'; +import { validate, postValidation, createPostWithMusicItemValidation } from '../middleware/validation.middleware'; +import { getDatabasePool } from '../database/connection'; +import { CustomError } from '../middleware/error.middleware'; +import { logger } from '../config/logger'; + +const router = Router(); +const pool = getDatabasePool(); + +router.post( + '/', + ratingLimiter, + authMiddleware, + validate(postValidation), + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { musicItemId, rating, text } = req.body; + + const musicItemResult = await pool.query( + 'SELECT id FROM music_items WHERE id = $1', + [musicItemId] + ); + + if (musicItemResult.rows.length === 0) { + throw new CustomError('Music item not found', 404); + } + + const existingRating = await pool.query( + 'SELECT id FROM ratings WHERE user_id = $1 AND music_item_id = $2', + [req.userId, musicItemId] + ); + + if (existingRating.rows.length > 0) { + await pool.query( + `UPDATE ratings + SET rating = $1, updated_at = NOW() + WHERE user_id = $2 AND music_item_id = $3 + RETURNING *`, + [rating, req.userId, musicItemId] + ); + } else { + await pool.query( + `INSERT INTO ratings (user_id, music_item_id, rating, tags) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [req.userId, musicItemId, rating, JSON.stringify([])] + ); + } + + const postResult = await pool.query( + `INSERT INTO posts (user_id, music_item_id, rating, text) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [req.userId, musicItemId, rating, text || null] + ); + + const userResult = await pool.query( + 'SELECT username FROM users WHERE id = $1', + [req.userId] + ); + + const musicItemDetailResult = await pool.query( + `SELECT id, type, title, artist, image_url, spotify_id, apple_music_id, metadata + FROM music_items WHERE id = $1`, + [musicItemId] + ); + + const musicItem = musicItemDetailResult.rows[0]; + + logger.info('Post created', { + userId: req.userId, + musicItemId, + rating, + postId: postResult.rows[0].id + }); + + res.json({ + success: true, + data: { + post: { + id: postResult.rows[0].id, + username: userResult.rows[0].username, + text: postResult.rows[0].text, + rating: postResult.rows[0].rating, + musicItem: { + id: musicItem.id, + type: musicItem.type, + title: musicItem.title, + artist: musicItem.artist, + imageUrl: musicItem.image_url, + spotifyId: musicItem.spotify_id, + appleMusicId: musicItem.apple_music_id, + metadata: musicItem.metadata + }, + createdAt: postResult.rows[0].created_at + } + }, + message: 'Post created successfully' + }); + } catch (error) { + next(error); + } + } +); + +router.post( + '/create', + ratingLimiter, + authMiddleware, + validate(createPostWithMusicItemValidation), + async (req: AuthRequest, res, next) => { + try { + if (!req.userId) { + throw new CustomError('Unauthorized', 401); + } + + const { name, category, rating, text } = req.body; + + let musicItemResult = await pool.query( + '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 { + const artistValue = category === 'artist' ? name.trim() : ''; + const insertResult = await pool.query( + `INSERT INTO music_items (type, title, artist) + VALUES ($1, $2, $3) + RETURNING id`, + [category, name.trim(), artistValue] + ); + musicItemId = insertResult.rows[0].id; + } + const existingRating = await pool.query( + 'SELECT id FROM ratings WHERE user_id = $1 AND music_item_id = $2', + [req.userId, musicItemId] + ); + + if (existingRating.rows.length > 0) { + await pool.query( + `UPDATE ratings + SET rating = $1, updated_at = NOW() + WHERE user_id = $2 AND music_item_id = $3`, + [rating, req.userId, musicItemId] + ); + } else { + await pool.query( + `INSERT INTO ratings (user_id, music_item_id, rating, tags) + VALUES ($1, $2, $3, $4)`, + [req.userId, musicItemId, rating, JSON.stringify([])] + ); + } + + const postResult = await pool.query( + `INSERT INTO posts (user_id, music_item_id, rating, text) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [req.userId, musicItemId, rating, text || null] + ); + + const userResult = await pool.query( + 'SELECT username FROM users WHERE id = $1', + [req.userId] + ); + + const musicItemDetailResult = await pool.query( + `SELECT id, type, title, artist, image_url, spotify_id, apple_music_id, metadata + FROM music_items WHERE id = $1`, + [musicItemId] + ); + + const musicItem = musicItemDetailResult.rows[0]; + + logger.info('Post created with new music item', { + userId: req.userId, + musicItemId, + rating, + postId: postResult.rows[0].id + }); + + res.json({ + success: true, + data: { + post: { + id: postResult.rows[0].id, + username: userResult.rows[0].username, + text: postResult.rows[0].text, + rating: postResult.rows[0].rating, + musicItem: { + id: musicItem.id, + type: musicItem.type, + title: musicItem.title, + artist: musicItem.artist, + imageUrl: musicItem.image_url, + spotifyId: musicItem.spotify_id, + appleMusicId: musicItem.apple_music_id, + metadata: musicItem.metadata + }, + createdAt: postResult.rows[0].created_at + } + }, + message: 'Post created successfully' + }); + } catch (error) { + next(error); + } + } +); + +router.get( + '/feed', + authMiddleware, + async (req: AuthRequest, res, next) => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const offset = (page - 1) * limit; + + const result = await pool.query( + `SELECT + p.id, + p.rating, + p.text, + p.created_at, + u.username, + mi.id as music_item_id, + mi.type, + mi.title, + mi.artist, + mi.image_url, + mi.spotify_id, + mi.apple_music_id, + mi.metadata + FROM posts p + JOIN users u ON p.user_id = u.id + JOIN music_items mi ON p.music_item_id = mi.id + WHERE u.deleted_at IS NULL + ORDER BY p.created_at DESC + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + + const countResult = await pool.query( + 'SELECT COUNT(*) as total FROM posts p JOIN users u ON p.user_id = u.id WHERE u.deleted_at IS NULL' + ); + + const total = parseInt(countResult.rows[0].total); + const hasMore = offset + limit < total; + const nextPage = hasMore ? page + 1 : null; + + const posts = result.rows.map((row: any) => ({ + id: row.id, + username: row.username, + text: row.text, + rating: row.rating, + musicItem: { + id: row.music_item_id, + type: row.type, + title: row.title, + artist: row.artist, + imageUrl: row.image_url, + spotifyId: row.spotify_id, + appleMusicId: row.apple_music_id, + metadata: row.metadata + }, + createdAt: row.created_at + })); + + res.json({ + success: true, + data: { + data: posts, + pagination: { + page, + limit, + total, + hasMore, + nextPage + } + } + }); + } catch (error) { + next(error); + } + } +); + +export default router; diff --git a/backend/src/routes/profile.ts b/backend/src/routes/profile.ts index 857e297..ec77748 100644 --- a/backend/src/routes/profile.ts +++ b/backend/src/routes/profile.ts @@ -2,9 +2,13 @@ import { Router } from 'express'; import { authMiddleware, AuthRequest } from '../middleware/auth.middleware'; import { getDatabasePool } from '../database/connection'; import { CustomError } from '../middleware/error.middleware'; +import { AuthService } from '../services/auth.service'; +import { body } from 'express-validator'; +import { validate } from '../middleware/validation.middleware'; const router = Router(); const pool = getDatabasePool(); +const authService = new AuthService(); router.get( '/', @@ -186,19 +190,47 @@ router.get( router.put( '/', authMiddleware, + validate([ + body('email') + .optional() + .trim() + .isEmail() + .withMessage('Valid email address is required') + .normalizeEmail() + .toLowerCase(), + body('firstName') + .optional() + .trim() + .isLength({ min: 1, max: 50 }) + .withMessage('First name must be between 1 and 50 characters'), + body('lastName') + .optional() + .trim() + .isLength({ min: 1, max: 50 }) + .withMessage('Last name must be between 1 and 50 characters'), + body('username') + .optional() + .trim() + .isLength({ min: 3, max: 30 }) + .withMessage('Username must be between 3 and 30 characters') + .matches(/^[a-zA-Z0-9_]+$/) + .withMessage('Username can only contain letters, numbers, and underscores') + ]), async (req: AuthRequest, res, next) => { try { if (!req.userId) { throw new CustomError('Unauthorized', 401); } - const { username } = req.body; - const updates: string[] = []; - const values: any[] = []; - let paramCount = 1; + const { email, firstName, lastName, username } = req.body; + + if (!email && firstName === undefined && lastName === undefined && !username) { + throw new CustomError('At least one field must be provided for update', 400); + } + + await authService.updateProfile(req.userId, email, firstName, lastName); if (username) { - const existingUser = await pool.query( 'SELECT id FROM users WHERE username = $1 AND id != $2', [username, req.userId] @@ -208,24 +240,14 @@ router.put( throw new CustomError('Username already taken', 409); } - updates.push(`username = $${paramCount++}`); - values.push(username); - } - - if (updates.length === 0) { - throw new CustomError('No valid fields to update', 400); + await pool.query( + 'UPDATE users SET username = $1, updated_at = NOW() WHERE id = $2', + [username, req.userId] + ); } - updates.push(`updated_at = NOW()`); - values.push(req.userId); - - await pool.query( - `UPDATE users SET ${updates.join(', ')} WHERE id = $${paramCount}`, - values - ); - const userResult = await pool.query( - `SELECT id, email, username, email_verified, mfa_enabled, role, oauth_provider, oauth_id, last_login_at, created_at, updated_at + `SELECT id, email, username, first_name, last_name, email_verified, mfa_enabled, role, oauth_provider, oauth_id, last_login_at, created_at, updated_at FROM users WHERE id = $1 AND deleted_at IS NULL`, [req.userId] ); diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index 532c55d..40b0ea0 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -136,7 +136,7 @@ const handleDiscordInteraction = async (req: DiscordRequest, res: Response) => { await sendDiscordFollowup( applicationId, interactionToken, - `✅ **GitHub Issue Created!**\n${githubResponse.data.html_url}` + `GitHub Issue Created!\n${githubResponse.data.html_url}` ); console.log('Done!'); } catch (error) { diff --git a/backend/src/scripts/delete-supabase-user.ts b/backend/src/scripts/delete-supabase-user.ts new file mode 100644 index 0000000..945b909 --- /dev/null +++ b/backend/src/scripts/delete-supabase-user.ts @@ -0,0 +1,121 @@ +import dotenv from 'dotenv'; +import { createClient } from '@supabase/supabase-js'; + +dotenv.config(); + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in .env'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } +}); + +async function deleteUserByEmail(email: string) { + try { + console.log(`Searching for user with email: ${email}...`); + + const { data: listData, error: listError } = await supabase.auth.admin.listUsers(); + + if (listError) { + console.error('Error listing users:', listError.message); + return; + } + + const user = listData.users.find(u => u.email?.toLowerCase() === email.toLowerCase()); + + if (!user) { + console.log(`No user found with email: ${email}`); + return; + } + + console.log(`Found user: ${user.id} (${user.email})`); + console.log(`Created at: ${user.created_at}`); + console.log(`Email confirmed: ${user.email_confirmed_at ? 'Yes' : 'No'}`); + + const { error: deleteError } = await supabase.auth.admin.deleteUser(user.id); + + if (deleteError) { + console.error('Error deleting user:', deleteError.message); + return; + } + + console.log(`Successfully deleted user: ${email} (${user.id})`); + } catch (error) { + console.error('Unexpected error:', error instanceof Error ? error.message : String(error)); + } +} + +async function deleteUserById(userId: string) { + try { + console.log(`Deleting user with ID: ${userId}...`); + + const { error } = await supabase.auth.admin.deleteUser(userId); + + if (error) { + console.error('Error deleting user:', error.message); + return; + } + + console.log(`Successfully deleted user: ${userId}`); + } catch (error) { + console.error('Unexpected error:', error instanceof Error ? error.message : String(error)); + } +} + +async function listUsers() { + try { + console.log('Fetching all users...\n'); + + const { data, error } = await supabase.auth.admin.listUsers(); + + if (error) { + console.error('Error listing users:', error.message); + return; + } + + if (data.users.length === 0) { + console.log('No users found.'); + return; + } + + console.log(`Found ${data.users.length} user(s):\n`); + data.users.forEach((user, index) => { + console.log(`${index + 1}. ${user.email || 'No email'}`); + console.log(` ID: ${user.id}`); + console.log(` Created: ${user.created_at}`); + console.log(` Email Verified: ${user.email_confirmed_at ? 'Yes' : 'No'}`); + console.log(''); + }); + } catch (error) { + console.error('Unexpected error:', error instanceof Error ? error.message : String(error)); + } +} + +const args = process.argv.slice(2); +const command = args[0]; +const value = args[1]; + +if (command === 'delete-email' && value) { + deleteUserByEmail(value).then(() => process.exit(0)); +} else if (command === 'delete-id' && value) { + deleteUserById(value).then(() => process.exit(0)); +} else if (command === 'list') { + listUsers().then(() => process.exit(0)); +} else { + console.log('Usage:'); + console.log(' npm run delete-user list - List all users'); + console.log(' npm run delete-user delete-email - Delete user by email'); + console.log(' npm run delete-user delete-id - Delete user by ID'); + console.log(''); + console.log('Example:'); + console.log(' npm run delete-user delete-email aprameyakannan@gmail.com'); + process.exit(1); +} diff --git a/backend/src/security/identity-federation.ts b/backend/src/security/identity-federation.ts deleted file mode 100644 index 3d9a1a3..0000000 --- a/backend/src/security/identity-federation.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { getDatabasePool } from '../database/connection'; -import { logger } from '../config/logger'; -import { CustomError } from '../middleware/error.middleware'; - -export interface OAuthUser { - id: string; - email: string; - name?: string; - provider: 'apple' | 'google' | 'spotify'; -} - -export class IdentityFederationService { - private pool = getDatabasePool(); - - async authenticateWithApple(_idToken: string, userIdentifier?: string): Promise { - - logger.info('Apple Sign In attempted', { userIdentifier }); - - throw new CustomError('Apple Sign In not yet fully implemented', 501); - } - - async authenticateWithGoogle(_accessToken: string, _idToken?: string): Promise { - - logger.info('Google Sign In attempted'); - - throw new CustomError('Google Sign In not yet fully implemented', 501); - } - - async authenticateWithSpotify(_accessToken: string, _refreshToken?: string): Promise { - - logger.info('Spotify OAuth attempted'); - - throw new CustomError('Spotify OAuth not yet fully implemented', 501); - } - - async findOrCreateOAuthUser(oauthUser: OAuthUser): Promise<{ user: any; tokens: any }> { - - const existingUser = await this.pool.query( - 'SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2 AND deleted_at IS NULL', - [oauthUser.provider, oauthUser.id] - ); - - if (existingUser.rows.length > 0) { - - const user = existingUser.rows[0]; - - await this.pool.query( - 'UPDATE users SET last_login_at = NOW() WHERE id = $1', - [user.id] - ); - - const { AuthService } = await import('../services/auth.service'); - const authService = new AuthService(); - const tokens = await authService.generateTokens(user.id, user.email, user.role); - - await this.pool.query( - `INSERT INTO refresh_tokens (user_id, token, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '7 days') - ON CONFLICT (token) DO NOTHING`, - [user.id, tokens.refreshToken] - ); - - return { user, tokens }; - } - - const emailUser = await this.pool.query( - 'SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL', - [oauthUser.email] - ); - - if (emailUser.rows.length > 0) { - - await this.pool.query( - 'UPDATE users SET oauth_provider = $1, oauth_id = $2, last_login_at = NOW() WHERE id = $3', - [oauthUser.provider, oauthUser.id, emailUser.rows[0].id] - ); - - const user = emailUser.rows[0]; - const { AuthService } = await import('../services/auth.service'); - const authService = new AuthService(); - const tokens = await authService.generateTokens(user.id, user.email, user.role); - - await this.pool.query( - `INSERT INTO refresh_tokens (user_id, token, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '7 days') - ON CONFLICT (token) DO NOTHING`, - [user.id, tokens.refreshToken] - ); - - return { user, tokens }; - } - - let username = oauthUser.email.split('@')[0] + '_' + oauthUser.provider; - - let uniqueUsername = username; - let counter = 1; - while (true) { - const existing = await this.pool.query( - 'SELECT id FROM users WHERE username = $1', - [uniqueUsername] - ); - if (existing.rows.length === 0) break; - uniqueUsername = `${username}${counter}`; - counter++; - } - - const result = await this.pool.query( - `INSERT INTO users (email, username, oauth_provider, oauth_id, email_verified, role) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING *`, - [oauthUser.email, uniqueUsername, oauthUser.provider, oauthUser.id, true, 'user'] - ); - - const newUser = result.rows[0]; - const { AuthService } = await import('../services/auth.service'); - const authService = new AuthService(); - const tokens = await authService.generateTokens(newUser.id, newUser.email, newUser.role); - - await this.pool.query( - `INSERT INTO refresh_tokens (user_id, token, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '7 days')`, - [newUser.id, tokens.refreshToken] - ); - - logger.info('OAuth user created', { userId: newUser.id }); - return { user: newUser, tokens }; - } -} diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 849861c..b1d9e0a 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,9 +1,9 @@ -import bcrypt from 'bcrypt'; -import jwt from 'jsonwebtoken'; -import { v4 as uuidv4 } from 'uuid'; + + import { getDatabasePool } from '../database/connection'; import { logger } from '../config/logger'; import { CustomError } from '../middleware/error.middleware'; +import { supabaseService } from './supabase.service'; export interface User { id: string; @@ -15,6 +15,9 @@ export interface User { role: string; oauth_provider?: string; oauth_id?: string; + first_name?: string; + last_name?: string; + supabase_auth_id?: string; created_at: Date; updated_at: Date; } @@ -24,152 +27,149 @@ export interface AuthTokens { refreshToken: string; expiresIn: number; tokenType: string; + emailVerified?: boolean; } export class AuthService { private pool = getDatabasePool(); - private readonly jwtSecret: string; - private readonly jwtRefreshSecret: string; - private readonly jwtExpiresIn: string; - private readonly jwtRefreshExpiresIn: string; - private readonly bcryptRounds: number; + constructor() { - this.jwtSecret = process.env.JWT_SECRET || 'default-secret-change-in-production'; - this.jwtRefreshSecret = process.env.JWT_REFRESH_SECRET || 'default-refresh-secret-change-in-production'; - this.jwtExpiresIn = process.env.JWT_EXPIRES_IN || '15m'; - this.jwtRefreshExpiresIn = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; - this.bcryptRounds = parseInt(process.env.BCRYPT_ROUNDS || '10'); + } - async signup(username: string, password: string): Promise { - + async signup(email: string, username: string, password: string, firstName: string, lastName: string): Promise { + try { const existingUser = await this.pool.query( - 'SELECT id FROM users WHERE username = $1', - [username] + 'SELECT id FROM users WHERE username = $1 OR email = $2', + [username, email.toLowerCase()] ); if (existingUser.rows.length > 0) { + const existing = existingUser.rows[0]; + const emailCheck = await this.pool.query('SELECT email FROM users WHERE id = $1', [existing.id]); + if (emailCheck.rows[0]?.email?.toLowerCase() === email.toLowerCase()) { + throw new CustomError('This email is already registered', 409); + } throw new CustomError('Username already exists', 409); } - const passwordHash = await bcrypt.hash(password, this.bcryptRounds); - - const placeholderEmail = `${username}@musiq.local`; + let supabaseAuthId: string; + try { + supabaseAuthId = await supabaseService.createAuthUser( + email.toLowerCase(), + password, + { first_name: firstName, last_name: lastName } + ); + } catch (error: any) { + if (error.message?.includes('already registered') || error.message?.includes('already exists')) { + const existingSupabaseUser = await supabaseService.getUserByEmail(email.toLowerCase()); + if (existingSupabaseUser) { + logger.warn('User exists in Supabase but not in local database', { email, supabaseAuthId: existingSupabaseUser.id }); + throw new CustomError('This email is already registered. Please try logging in instead.', 409); + } + } + throw error; + } const result = await this.pool.query( - `INSERT INTO users (email, username, password_hash, role) - VALUES ($1, $2, $3, $4) - RETURNING id, email, username, role, created_at, updated_at`, - [placeholderEmail, username, passwordHash, 'user'] + `INSERT INTO users (email, username, password_hash, role, first_name, last_name, supabase_auth_id, email_verified) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, email, username, role, created_at, updated_at`, + [email.toLowerCase(), username, null, 'user', firstName, lastName, supabaseAuthId, false] // Set email_verified to false initially ); const user = result.rows[0]; - const tokens = await this.generateTokens(user.id, user.username, user.role); - - await this.storeRefreshToken(user.id, tokens.refreshToken, null, null, null); - - logger.info('User signed up', { userId: user.id, username }); - - return tokens; - } - - async login(username: string, password: string, deviceId?: string, ipAddress?: string, userAgent?: string): Promise { - - const result = await this.pool.query( - 'SELECT id, email, username, password_hash, role, mfa_enabled FROM users WHERE username = $1 AND deleted_at IS NULL', - [username] - ); - - if (result.rows.length === 0) { - throw new CustomError('Invalid username or password', 401); - } - - const user = result.rows[0]; - - if (!user.password_hash) { - throw new CustomError('Invalid username or password', 401); - } - - const isValidPassword = await bcrypt.compare(password, user.password_hash); - if (!isValidPassword) { - throw new CustomError('Invalid username or password', 401); + logger.info('User signed up', { userId: user.id, username, email, supabaseAuthId }); + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Error during signup', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to create user account', 500); } + } - if (user.mfa_enabled) { + async login(email: string, password: string): Promise { + try { + const user = await this.getUserByEmail(email.toLowerCase()); - logger.warn('User has MFA enabled but MFA verification is not implemented', { userId: user.id }); + if (!user) { + throw new CustomError('Invalid email or password', 401); + } + + if (user.supabase_auth_id) { + const supabaseSession = await supabaseService.signInWithPassword(email.toLowerCase(), password); + + await this.pool.query( + 'UPDATE users SET last_login_at = NOW() WHERE id = $1', + [user.id] + ); + + logger.info('User logged in with Supabase', { userId: user.id, email }); + + return { + accessToken: supabaseSession.access_token, + refreshToken: supabaseSession.refresh_token, + expiresIn: supabaseSession.expires_in, + tokenType: supabaseSession.token_type, + 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) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Error during login', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Login failed', 500); } - - await this.pool.query( - 'UPDATE users SET last_login_at = NOW() WHERE id = $1', - [user.id] - ); - - const tokens = await this.generateTokens(user.id, user.username, user.role); - - await this.storeRefreshToken(user.id, tokens.refreshToken, deviceId, ipAddress, userAgent); - - logger.info('User logged in', { userId: user.id, username }); - - return tokens; } - async refreshToken(refreshToken: string): Promise { - + async forgotPassword(email: string): Promise { try { - jwt.verify(refreshToken, this.jwtRefreshSecret); - } catch (error: unknown) { - throw new CustomError('Invalid refresh token', 401); + await supabaseService.sendPasswordResetEmail(email); + logger.info('Password reset email sent', { email }); + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Error sending password reset email', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to send password reset email', 500); } + } - const tokenResult = await this.pool.query( - `SELECT rt.*, u.id as user_id, u.username, u.role - FROM refresh_tokens rt - JOIN users u ON rt.user_id = u.id - WHERE rt.token = $1 AND rt.revoked_at IS NULL AND rt.expires_at > NOW() AND u.deleted_at IS NULL`, - [refreshToken] - ); - - if (tokenResult.rows.length === 0) { - throw new CustomError('Invalid or expired refresh token', 401); - } - - const tokenData = tokenResult.rows[0]; - - await this.pool.query( - 'UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1', - [tokenData.id] - ); - const tokens = await this.generateTokens(tokenData.user_id, tokenData.username, tokenData.role); - await this.storeRefreshToken( - tokenData.user_id, - tokens.refreshToken, - tokenData.device_id, - tokenData.ip_address, - tokenData.user_agent - ); - logger.info('Token refreshed', { userId: tokenData.user_id }); - return tokens; + async refreshToken(_refreshToken: string): Promise { + 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) { + throw error; + } + logger.error('Error during token refresh', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Token refresh failed', 500); + } } - async logout(refreshToken: string): Promise { - await this.pool.query( - 'UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1', - [refreshToken] - ); - - logger.info('User logged out'); + async logout(): Promise { + // 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)'); } async getUserById(userId: string): Promise { const result = await this.pool.query( - `SELECT id, email, username, email_verified, mfa_enabled, role, oauth_provider, oauth_id, last_login_at, created_at, updated_at + `SELECT id, email, username, email_verified, mfa_enabled, role, oauth_provider, oauth_id, first_name, last_name, supabase_auth_id, last_login_at, created_at, updated_at FROM users WHERE id = $1 AND deleted_at IS NULL`, [userId] ); @@ -183,9 +183,9 @@ export class AuthService { async getUserByEmail(email: string): Promise { const result = await this.pool.query( - `SELECT id, email, username, email_verified, mfa_enabled, role, oauth_provider, oauth_id, last_login_at, created_at, updated_at + `SELECT id, email, username, email_verified, mfa_enabled, role, oauth_provider, oauth_id, first_name, last_name, supabase_auth_id, last_login_at, created_at, updated_at FROM users WHERE email = $1 AND deleted_at IS NULL`, - [email] + [email.toLowerCase()] ); if (result.rows.length === 0) { @@ -195,65 +195,92 @@ export class AuthService { return result.rows[0]; } - async generateTokens(userId: string, identifier: string, role: string): Promise { - const payload = { - userId, - identifier, - role, - iat: Math.floor(Date.now() / 1000) - }; - - const accessToken = jwt.sign(payload, this.jwtSecret, { - expiresIn: this.jwtExpiresIn, - jwtid: uuidv4() - } as jwt.SignOptions); - - const refreshToken = jwt.sign( - { userId, jti: uuidv4() }, - this.jwtRefreshSecret, - { expiresIn: this.jwtRefreshExpiresIn } as jwt.SignOptions - ); - - const expiresIn = this.parseExpiresIn(this.jwtExpiresIn); - - return { - accessToken, - refreshToken, - expiresIn, - tokenType: 'Bearer' - }; - } - - private async storeRefreshToken( - userId: string, - token: string, - deviceId: string | null | undefined, - ipAddress: string | null | undefined, - userAgent: string | null | undefined - ): Promise { - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + 7); - - await this.pool.query( - `INSERT INTO refresh_tokens (user_id, token, device_id, ip_address, user_agent, expires_at) - VALUES ($1, $2, $3, $4, $5, $6)`, - [userId, token, deviceId, ipAddress, userAgent, expiresAt] - ); + async verifyUserEmail(supabaseAuthId: string): Promise { + try { + await this.pool.query( + 'UPDATE users SET email_verified = true, updated_at = NOW() WHERE supabase_auth_id = $1', + [supabaseAuthId] + ); + logger.info('User email verified in local database', { supabaseAuthId }); + } catch (error) { + logger.error('Error verifying user email in local database', { error: error instanceof Error ? error.message : String(error), supabaseAuthId }); + throw new CustomError('Failed to verify email', 500); + } } - private parseExpiresIn(expiresIn: string): number { - const match = expiresIn.match(/(\d+)([smhd])/); - if (!match) return 900; - const value = parseInt(match[1]); - const unit = match[2]; - switch (unit) { - case 's': return value; - case 'm': return value * 60; - case 'h': return value * 3600; - case 'd': return value * 86400; - default: return 900; + async updateProfile(userId: string, email?: string, firstName?: string, lastName?: string): Promise { + try { + const user = await this.getUserById(userId); + if (!user) { + throw new CustomError('User not found', 404); + } + + let emailVerificationNeeded = false; + const updates: string[] = []; + const values: any[] = []; + let paramCount = 1; + + if (email && email.toLowerCase() !== user.email.toLowerCase()) { + if (!user.supabase_auth_id) { + throw new CustomError('Cannot update email for legacy user', 400); + } + + const emailCheck = await this.getUserByEmail(email.toLowerCase()); + if (emailCheck && emailCheck.id !== userId) { + throw new CustomError('This email is already registered', 409); + } + + await supabaseService.updateUserEmail(user.supabase_auth_id, email.toLowerCase()); + updates.push(`email = $${paramCount++}`); + values.push(email.toLowerCase()); + emailVerificationNeeded = true; + } + + if (firstName !== undefined && firstName !== user.first_name) { + updates.push(`first_name = $${paramCount++}`); + values.push(firstName); + } + + if (lastName !== undefined && lastName !== user.last_name) { + updates.push(`last_name = $${paramCount++}`); + values.push(lastName); + } + + if (updates.length === 0) { + return user; + } + + if (user.supabase_auth_id && (firstName !== undefined || lastName !== undefined)) { + const metadata: any = {}; + if (firstName !== undefined) metadata.first_name = firstName; + if (lastName !== undefined) metadata.last_name = lastName; + await supabaseService.updateUserMetadata(user.supabase_auth_id, metadata); + } + + updates.push(`updated_at = NOW()`); + if (emailVerificationNeeded) { + updates.push(`email_verified = false`); + } + + values.push(userId); + const query = `UPDATE users SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`; + + const result = await this.pool.query(query, values); + const updatedUser = result.rows[0]; + + logger.info('Profile updated', { userId, emailChanged: !!email, emailVerificationNeeded }); + + return updatedUser; + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Error updating profile', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to update profile', 500); } } + + } diff --git a/backend/src/services/supabase.service.ts b/backend/src/services/supabase.service.ts new file mode 100644 index 0000000..07eefc4 --- /dev/null +++ b/backend/src/services/supabase.service.ts @@ -0,0 +1,375 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { logger } from '../config/logger'; +import { CustomError } from '../middleware/error.middleware'; + +export interface SupabaseSession { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + user: { + id: string; + email: string; + email_confirmed_at: string | null; + user_metadata: { + first_name?: string; + last_name?: string; + }; + }; +} + +class SupabaseService { + private client: SupabaseClient; + + constructor() { + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseServiceKey) { + throw new Error('SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set'); + } + + this.client = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }); + } + + async getUserByEmail(email: string): Promise<{ id: string; email: string } | null> { + try { + let page = 1; + const perPage = 1000; + + while (true) { + const { data, error } = await this.client.auth.admin.listUsers({ + page, + perPage + }); + + if (error) { + logger.error('Supabase list users error', { error: error.message }); + return null; + } + + const user = data.users.find(u => u.email?.toLowerCase() === email.toLowerCase()); + if (user) { + return { id: user.id, email: user.email || email }; + } + + if (!data.users || data.users.length < perPage) { + break; + } + + page++; + } + + return null; + } catch (error) { + logger.error('Unexpected error getting user by email from Supabase', { error: error instanceof Error ? error.message : String(error) }); + return null; + } + } + + async createAuthUser(email: string, password: string, metadata: { first_name?: string; last_name?: string }): Promise { + try { + const { data, error } = await this.client.auth.admin.createUser({ + email, + password, + email_confirm: false, // Supabase will send verification email if configured + user_metadata: { + first_name: metadata.first_name || '', + last_name: metadata.last_name || '' + } + }); + + if (error) { + logger.error('Supabase create user error', { error: error.message, email, errorCode: error.status }); + + if (error.message.includes('already registered') || + error.message.includes('already exists') || + error.message.includes('User already registered') || + error.status === 422) { + + const existingUser = await this.getUserByEmail(email); + if (existingUser) { + logger.warn('User exists in Supabase but signup was attempted', { + email, + supabaseAuthId: existingUser.id, + message: 'User should try logging in instead' + }); + throw new CustomError('This email is already registered. Please try logging in instead.', 409); + } + + throw new CustomError('This email is already registered. Please try logging in instead.', 409); + } + + if (error.message.includes('Password')) { + throw new CustomError('Password does not meet requirements', 400); + } + + throw new CustomError(`Failed to create user: ${error.message}`, 400); + } + + if (!data.user) { + throw new CustomError('Failed to create user: No user data returned', 500); + } + + logger.info('Supabase user created', { userId: data.user.id, email }); + return data.user.id; + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error creating Supabase user', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to create user account', 500); + } + } + + async signInWithPassword(email: string, password: string): Promise { + try { + const { data, error } = await this.client.auth.signInWithPassword({ + email, + password + }); + + if (error) { + logger.error('Supabase sign in error', { error: error.message, email }); + + if (error.message.includes('Invalid login credentials') || error.message.includes('Invalid')) { + throw new CustomError('Invalid email or password', 401); + } + if (error.message.includes('Email not confirmed') || error.message.includes('not confirmed')) { + throw new CustomError('Please verify your email address', 403); + } + + throw new CustomError(`Login failed: ${error.message}`, 401); + } + + if (!data.session || !data.user) { + throw new CustomError('Login failed: No session data returned', 500); + } + + logger.info('Supabase user signed in', { userId: data.user.id, email }); + + return { + access_token: data.session.access_token, + refresh_token: data.session.refresh_token, + expires_in: data.session.expires_in || 3600, + token_type: data.session.token_type || 'bearer', + user: { + id: data.user.id, + email: data.user.email || email, + email_confirmed_at: data.user.email_confirmed_at ?? null, + user_metadata: data.user.user_metadata as { first_name?: string; last_name?: string } + } + }; + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error signing in Supabase user', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Login failed', 500); + } + } + + async sendPasswordResetEmail(email: string): Promise { + try { + const { error } = await this.client.auth.resetPasswordForEmail(email); + + if (error) { + logger.error('Supabase password reset email error', { error: error.message, email }); + throw new CustomError(`Failed to send password reset email: ${error.message}`, 400); + } + + logger.info('Supabase password reset email sent', { email }); + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error sending password reset email', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to send password reset email', 500); + } + } + + + + + + async updateUserMetadata(authId: string, metadata: { first_name?: string; last_name?: string }): Promise { + try { + const { error } = await this.client.auth.admin.updateUserById(authId, { + user_metadata: metadata + }); + + if (error) { + logger.error('Supabase update metadata error', { error: error.message, authId }); + throw new CustomError(`Failed to update user metadata: ${error.message}`, 400); + } + + logger.info('Supabase user metadata updated', { authId }); + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error updating user metadata', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to update user metadata', 500); + } + } + + async updateUserEmail(authId: string, newEmail: string): Promise { + try { + const { error } = await this.client.auth.admin.updateUserById(authId, { + email: newEmail, + email_confirm: false + }); + + if (error) { + logger.error('Supabase update email error', { error: error.message, authId }); + + if (error.message.includes('already registered') || error.message.includes('already exists')) { + throw new CustomError('This email is already registered', 409); + } + + throw new CustomError(`Failed to update email: ${error.message}`, 400); + } + + logger.info('Supabase user email updated', { authId, newEmail }); + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error updating user email', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to update email', 500); + } + } + + async getUserByAuthId(authId: string): Promise<{ id: string; email: string; email_confirmed_at: string | null; user_metadata: any } | null> { + try { + const { data, error } = await this.client.auth.admin.getUserById(authId); + + if (error) { + logger.error('Supabase get user error', { error: error.message, authId }); + return null; + } + + if (!data.user) { + return null; + } + + return { + id: data.user.id, + email: data.user.email || '', + email_confirmed_at: data.user.email_confirmed_at ?? null, + user_metadata: data.user.user_metadata + }; + } catch (error) { + logger.error('Unexpected error getting user by auth ID', { error: error instanceof Error ? error.message : String(error) }); + return null; + } + } + + async revokeSession(accessToken: string): Promise { + try { + const { error } = await this.client.auth.admin.signOut(accessToken); + + if (error) { + logger.error('Supabase revoke session error', { error: error.message }); + } else { + logger.info('Supabase session revoked'); + } + } catch (error) { + logger.error('Unexpected error revoking session', { error: error instanceof Error ? error.message : String(error) }); + } + } + + async verifyAccessToken(accessToken: string): Promise<{ userId: string; email: string; emailVerified: boolean } | null> { + try { + const { data, error } = await this.client.auth.getUser(accessToken); + + if (error || !data.user) { + return null; + } + + return { + userId: data.user.id, + email: data.user.email || '', + emailVerified: !!data.user.email_confirmed_at + }; + } catch (error) { + logger.error('Unexpected error verifying access token', { error: error instanceof Error ? error.message : String(error) }); + return null; + } + } + + async verifyOtp(tokenHash: string, type: 'recovery' | 'signup' | 'email_change' | 'magiclink'): Promise<{ user: any; session: any }> { + try { + const anonClient = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!); + const { data, error } = await anonClient.auth.verifyOtp({ token_hash: tokenHash, type }); + + if (error) { + logger.error('Supabase verify OTP error', { error: error.message, type }); + throw new CustomError(`Failed to verify OTP: ${error.message}`, 400); + } + + logger.info('OTP verified successfully', { type, userId: data.user?.id }); + return data; + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error verifying OTP', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to verify OTP', 500); + } + } + + async updatePassword(accessToken: string, newPassword: string): Promise { + try { + const anonClient = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, { + global: { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + }); + const { error } = await anonClient.auth.updateUser({ password: newPassword }); + + if (error) { + logger.error('Supabase update password error', { error: error.message }); + throw new CustomError(`Failed to update password: ${error.message}`, 400); + } + + logger.info('Password updated successfully'); + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error updating password', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to update password', 500); + } + } + + async updateUserPassword(authId: string, newPassword: string): Promise { + try { + const { error } = await this.client.auth.admin.updateUserById(authId, { + password: newPassword + }); + + if (error) { + logger.error('Supabase admin update password error', { error: error.message, authId }); + throw new CustomError(`Failed to update password: ${error.message}`, 400); + } + + logger.info('Password updated successfully for user', { authId }); + } catch (error) { + if (error instanceof CustomError) { + throw error; + } + logger.error('Unexpected error updating user password', { error: error instanceof Error ? error.message : String(error) }); + throw new CustomError('Failed to update password', 500); + } + } +} + +export const supabaseService = new SupabaseService(); diff --git a/frontend/MusIQ/Assets.xcassets/AccentColor.colorset/Contents.json b/frontend/MusIQ/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/frontend/MusIQ/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/frontend/MusIQ/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/frontend/MusIQ/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png deleted file mode 100644 index e92cda4..0000000 Binary files a/frontend/MusIQ/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png and /dev/null differ diff --git a/frontend/MusIQ/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/MusIQ/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index aa1e903..0000000 --- a/frontend/MusIQ/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "images": [ - { - "filename": "AppIcon-1024.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "filename": "AppIcon-1024.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "tinted" - } - ], - "filename": "AppIcon-1024.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/MusIQ/Assets.xcassets/Contents.json b/frontend/MusIQ/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/frontend/MusIQ/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/frontend/MusIQ/ContentView.swift b/frontend/MusIQ/ContentView.swift deleted file mode 100644 index bfae2f7..0000000 --- a/frontend/MusIQ/ContentView.swift +++ /dev/null @@ -1,107 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @StateObject private var appState = AppState.shared - @State private var showSplash = true - - var body: some View { - ZStack { - if showSplash { - SplashScreenView(onComplete: { - withAnimation { - showSplash = false - } - }) - .transition(.opacity) - } else { - Group { - switch appState.currentScreen { - case .splash: - SplashScreenView(onComplete: { - appState.completeOnboarding() - }) - case .onboarding: - OnboardingView(onComplete: { - appState.completeOnboarding() - }) - case .authentication: - AuthenticationView(appState: appState) - case .main: - MainAppView(appState: appState) - } - } - .transition(.opacity) - } - } - .animation(.easeInOut, value: appState.currentScreen) - .animation(.easeInOut, value: showSplash) - } -} - -struct AuthenticationView: View { - @ObservedObject var appState: AppState - @State private var isLogin = true - - var body: some View { - ZStack { - AppColors.background - .ignoresSafeArea() - - VStack(spacing: 0) { - - HStack(spacing: 0) { - Button(action: { - withAnimation { - isLogin = true - } - }) { - Text("Log In") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(isLogin ? AppColors.textPrimary : AppColors.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background( - isLogin ? - AppColors.cardBackground : - Color.clear - ) - } - - Button(action: { - withAnimation { - isLogin = false - } - }) { - Text("Sign Up") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(!isLogin ? AppColors.textPrimary : AppColors.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background( - !isLogin ? - AppColors.cardBackground : - Color.clear - ) - } - } - .background(AppColors.background) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(AppColors.border), - alignment: .bottom - ) - - if isLogin { - LoginView(appState: appState) - } else { - SignupView(appState: appState) - } - } - } - } -} - -#Preview { - ContentView() -} diff --git a/frontend/MusIQ/Info.plist.backup b/frontend/MusIQ/Info.plist.backup deleted file mode 100644 index 30107aa..0000000 --- a/frontend/MusIQ/Info.plist.backup +++ /dev/null @@ -1,17 +0,0 @@ - - - - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - com.musiq - - - - - diff --git a/frontend/MusIQ/Models/APIResponse.swift b/frontend/MusIQ/Models/APIResponse.swift deleted file mode 100644 index 75a7ae6..0000000 --- a/frontend/MusIQ/Models/APIResponse.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -struct APIResponse: Codable { - let success: Bool - let data: T? - let message: String? - let error: APIError? -} - -struct APIError: Codable { - let code: String - let message: String - let details: [String: AnyCodable]? - let stack: String? - - enum CodingKeys: String, CodingKey { - case code - case message - case details - case stack - } -} - -struct PaginatedResponse: Codable { - let data: [T] - let page: Int - let limit: Int - let total: Int - let hasMore: Bool -} diff --git a/frontend/MusIQ/Models/AuthToken.swift b/frontend/MusIQ/Models/AuthToken.swift deleted file mode 100644 index aa18240..0000000 --- a/frontend/MusIQ/Models/AuthToken.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -struct AuthToken: Codable { - let accessToken: String - let refreshToken: String - let expiresIn: Int - let tokenType: String - - enum CodingKeys: String, CodingKey { - case accessToken - case refreshToken - case expiresIn - case tokenType - } -} - -struct LoginRequest: Codable { - let username: String - let password: String -} - -struct SignupRequest: Codable { - let username: String - let password: String - let confirmPassword: String -} - -struct RefreshTokenRequest: Codable { - let refreshToken: String -} - -struct OAuthRequest: Codable { - let provider: String - let token: String - let idToken: String? -} diff --git a/frontend/MusIQ/Models/Friend.swift b/frontend/MusIQ/Models/Friend.swift deleted file mode 100644 index 3cde2fe..0000000 --- a/frontend/MusIQ/Models/Friend.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation - -enum FriendshipStatus: String, Codable { - case pending - case accepted - case blocked -} - -struct Friend: Identifiable, Codable { - let id: String - let name: String - let username: String - let avatar: String - let compatibility: Int - let topGenre: String - let sharedArtists: Int - let status: FriendshipStatus? - - enum CodingKeys: String, CodingKey { - case id - case name - case username - case avatar - case compatibility - case topGenre - case sharedArtists - case status - } -} - -struct Friendship: Identifiable, Codable { - let id: String - let userId: String - let friendId: String - let status: FriendshipStatus - let createdAt: Date - let updatedAt: Date - - enum CodingKeys: String, CodingKey { - case id - case userId - case friendId - case status - case createdAt - case updatedAt - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - userId = try container.decode(String.self, forKey: .userId) - friendId = try container.decode(String.self, forKey: .friendId) - status = try container.decode(FriendshipStatus.self, forKey: .status) - - let formatter = ISO8601DateFormatter() - let createdAtString = try container.decode(String.self, forKey: .createdAt) - let updatedAtString = try container.decode(String.self, forKey: .updatedAt) - createdAt = formatter.date(from: createdAtString) ?? Date() - updatedAt = formatter.date(from: updatedAtString) ?? Date() - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(userId, forKey: .userId) - try container.encode(friendId, forKey: .friendId) - try container.encode(status, forKey: .status) - - let formatter = ISO8601DateFormatter() - try container.encode(formatter.string(from: createdAt), forKey: .createdAt) - try container.encode(formatter.string(from: updatedAt), forKey: .updatedAt) - } -} diff --git a/frontend/MusIQ/Models/MusicItem.swift b/frontend/MusIQ/Models/MusicItem.swift deleted file mode 100644 index 01c4b33..0000000 --- a/frontend/MusIQ/Models/MusicItem.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Foundation - -enum MusicItemType: String, Codable { - case album - case song - case artist -} - -struct MusicItem: Identifiable, Codable { - let id: String - let type: MusicItemType - let title: String - let artist: String - let imageUrl: String - let rating: Double - let ratingCount: Int - let trending: Bool? - let trendingChange: Int? - let spotifyId: String? - let appleMusicId: String? - let metadata: [String: AnyCodable]? - - init( - id: String, - type: MusicItemType, - title: String, - artist: String, - imageUrl: String, - rating: Double, - ratingCount: Int, - trending: Bool? = nil, - trendingChange: Int? = nil, - spotifyId: String? = nil, - appleMusicId: String? = nil, - metadata: [String: AnyCodable]? = nil - ) { - self.id = id - self.type = type - self.title = title - self.artist = artist - self.imageUrl = imageUrl - self.rating = rating - self.ratingCount = ratingCount - self.trending = trending - self.trendingChange = trendingChange - self.spotifyId = spotifyId - self.appleMusicId = appleMusicId - self.metadata = metadata - } - - enum CodingKeys: String, CodingKey { - case id - case type - case title - case artist - case imageUrl - case rating - case ratingCount - case trending - case trendingChange - case spotifyId - case appleMusicId - case metadata - } -} - -struct AnyCodable: Codable { - let value: Any - - init(_ value: Any) { - self.value = value - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let bool = try? container.decode(Bool.self) { - value = bool - } else if let int = try? container.decode(Int.self) { - value = int - } else if let double = try? container.decode(Double.self) { - value = double - } else if let string = try? container.decode(String.self) { - value = string - } else if let array = try? container.decode([AnyCodable].self) { - value = array.map { $0.value } - } else if let dictionary = try? container.decode([String: AnyCodable].self) { - value = dictionary.mapValues { $0.value } - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - switch value { - case let bool as Bool: - try container.encode(bool) - case let int as Int: - try container.encode(int) - case let double as Double: - try container.encode(double) - case let string as String: - try container.encode(string) - case let array as [Any]: - try container.encode(array.map { AnyCodable($0) }) - case let dictionary as [String: Any]: - try container.encode(dictionary.mapValues { AnyCodable($0) }) - default: - throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded")) - } - } -} diff --git a/frontend/MusIQ/Models/Notification.swift b/frontend/MusIQ/Models/Notification.swift deleted file mode 100644 index c78994e..0000000 --- a/frontend/MusIQ/Models/Notification.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation - -enum NotificationType: String, Codable { - case impact - case badge - case social - case trending -} - -struct AppNotification: Identifiable, Codable { - let id: String - let userId: String - let type: NotificationType - let title: String - let message: String - let read: Bool - let metadata: [String: AnyCodable]? - let createdAt: Date - - init( - id: String, - userId: String, - type: NotificationType, - title: String, - message: String, - read: Bool, - metadata: [String: AnyCodable]? = nil, - createdAt: Date - ) { - self.id = id - self.userId = userId - self.type = type - self.title = title - self.message = message - self.read = read - self.metadata = metadata - self.createdAt = createdAt - } - - enum CodingKeys: String, CodingKey { - case id - case userId - case type - case title - case message - case read - case metadata - case createdAt - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - userId = try container.decode(String.self, forKey: .userId) - type = try container.decode(NotificationType.self, forKey: .type) - title = try container.decode(String.self, forKey: .title) - message = try container.decode(String.self, forKey: .message) - read = try container.decode(Bool.self, forKey: .read) - metadata = try container.decodeIfPresent([String: AnyCodable].self, forKey: .metadata) - - let formatter = ISO8601DateFormatter() - let createdAtString = try container.decode(String.self, forKey: .createdAt) - createdAt = formatter.date(from: createdAtString) ?? Date() - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(userId, forKey: .userId) - try container.encode(type, forKey: .type) - try container.encode(title, forKey: .title) - try container.encode(message, forKey: .message) - try container.encode(read, forKey: .read) - try container.encodeIfPresent(metadata, forKey: .metadata) - - let formatter = ISO8601DateFormatter() - try container.encode(formatter.string(from: createdAt), forKey: .createdAt) - } -} - -extension AppNotification { - var timeAgo: String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: createdAt, relativeTo: Date()) - } -} diff --git a/frontend/MusIQ/Models/Rating.swift b/frontend/MusIQ/Models/Rating.swift deleted file mode 100644 index 0e9b91a..0000000 --- a/frontend/MusIQ/Models/Rating.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -struct Rating: Identifiable, Codable { - let id: String - let userId: String - let musicItemId: String - let rating: Int - let tags: [String] - let createdAt: Date - let updatedAt: Date - - enum CodingKeys: String, CodingKey { - case id - case userId - case musicItemId - case rating - case tags - case createdAt - case updatedAt - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - userId = try container.decode(String.self, forKey: .userId) - musicItemId = try container.decode(String.self, forKey: .musicItemId) - rating = try container.decode(Int.self, forKey: .rating) - tags = try container.decode([String].self, forKey: .tags) - - let formatter = ISO8601DateFormatter() - let createdAtString = try container.decode(String.self, forKey: .createdAt) - let updatedAtString = try container.decode(String.self, forKey: .updatedAt) - createdAt = formatter.date(from: createdAtString) ?? Date() - updatedAt = formatter.date(from: updatedAtString) ?? Date() - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(userId, forKey: .userId) - try container.encode(musicItemId, forKey: .musicItemId) - try container.encode(rating, forKey: .rating) - try container.encode(tags, forKey: .tags) - - let formatter = ISO8601DateFormatter() - try container.encode(formatter.string(from: createdAt), forKey: .createdAt) - try container.encode(formatter.string(from: updatedAt), forKey: .updatedAt) - } -} - -struct CreateRatingRequest: Codable { - let musicItemId: String - let rating: Int - let tags: [String] -} - -struct RatingResponse: Codable { - let rating: Rating - let message: String -} diff --git a/frontend/MusIQ/Models/User.swift b/frontend/MusIQ/Models/User.swift deleted file mode 100644 index 2810b99..0000000 --- a/frontend/MusIQ/Models/User.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation - -enum UserRole: String, Codable { - case user - case moderator - case admin -} - -enum OAuthProvider: String, Codable { - case apple - case google -} - -struct User: Identifiable, Codable { - let id: String - let email: String - let username: String - let emailVerified: Bool - let mfaEnabled: Bool - let role: UserRole - let oauthProvider: OAuthProvider? - let oauthId: String? - let lastLoginAt: Date? - let createdAt: Date - let updatedAt: Date - - enum CodingKeys: String, CodingKey { - case id - case email - case username - case emailVerified = "email_verified" - case mfaEnabled = "mfa_enabled" - case role - case oauthProvider = "oauth_provider" - case oauthId = "oauth_id" - case lastLoginAt = "last_login_at" - case createdAt = "created_at" - case updatedAt = "updated_at" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - email = try container.decode(String.self, forKey: .email) - username = try container.decode(String.self, forKey: .username) - emailVerified = try container.decode(Bool.self, forKey: .emailVerified) - mfaEnabled = try container.decode(Bool.self, forKey: .mfaEnabled) - role = try container.decode(UserRole.self, forKey: .role) - oauthProvider = try container.decodeIfPresent(OAuthProvider.self, forKey: .oauthProvider) - oauthId = try container.decodeIfPresent(String.self, forKey: .oauthId) - - if let lastLoginString = try? container.decode(String.self, forKey: .lastLoginAt) { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - lastLoginAt = formatter.date(from: lastLoginString) - } else { - lastLoginAt = nil - } - - let createdAtString = try container.decode(String.self, forKey: .createdAt) - let updatedAtString = try container.decode(String.self, forKey: .updatedAt) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - createdAt = formatter.date(from: createdAtString) ?? Date() - updatedAt = formatter.date(from: updatedAtString) ?? Date() - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(email, forKey: .email) - try container.encode(username, forKey: .username) - try container.encode(emailVerified, forKey: .emailVerified) - try container.encode(mfaEnabled, forKey: .mfaEnabled) - try container.encode(role, forKey: .role) - try container.encodeIfPresent(oauthProvider, forKey: .oauthProvider) - try container.encodeIfPresent(oauthId, forKey: .oauthId) - - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let lastLoginAt = lastLoginAt { - try container.encode(formatter.string(from: lastLoginAt), forKey: .lastLoginAt) - } - try container.encode(formatter.string(from: createdAt), forKey: .createdAt) - try container.encode(formatter.string(from: updatedAt), forKey: .updatedAt) - } -} - -struct UserProfile: Codable { - let user: User - let tasteScore: Int - let totalRatings: Int - let influence: Int -} diff --git a/frontend/MusIQ/MusicAppApp.swift b/frontend/MusIQ/MusicAppApp.swift deleted file mode 100644 index 51a8f21..0000000 --- a/frontend/MusIQ/MusicAppApp.swift +++ /dev/null @@ -1,51 +0,0 @@ -import SwiftUI - -@main -struct MusIQApp: App { - @StateObject private var appState = AppState.shared - - var body: some Scene { - WindowGroup { - ContentView() - .environmentObject(appState) - .onOpenURL { url in - - handleOAuthCallback(url: url) - } - } - } - - private func handleOAuthCallback(url: URL) { - - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - - guard let host = url.host, - let queryItems = components?.queryItems, - let code = queryItems.first(where: { $0.name == "code" })?.value else { - return - } - - var provider: OAuthProviderType? - if host.contains("google") { - provider = .google - } else if host.contains("apple") { - provider = .apple - } - - guard let provider = provider else { - return - } - - let idToken = queryItems.first(where: { $0.name == "id_token" })?.value - - NotificationCenter.default.post( - name: NSNotification.Name("OAuthCallback"), - object: nil, - userInfo: [ - "provider": provider.rawValue, - "code": code, - "idToken": idToken as Any - ] - ) - } -} diff --git a/frontend/MusIQ/Services/APIService.swift b/frontend/MusIQ/Services/APIService.swift deleted file mode 100644 index cfd9e48..0000000 --- a/frontend/MusIQ/Services/APIService.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation - -class APIService { - static let shared = APIService() - - private let baseURL: String - private let session: URLSession - - init(baseURL: String? = nil) { - self.baseURL = baseURL ?? ProcessInfo.processInfo.environment["API_BASE_URL"] ?? "http://localhost:3000/api" - - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = 30 - configuration.timeoutIntervalForResource = 60 - self.session = URLSession(configuration: configuration) - } - - func request( - endpoint: String, - method: HTTPMethod = .get, - body: Encodable? = nil, - requiresAuth: Bool = true - ) async throws -> T { - guard let url = URL(string: "\(baseURL)\(endpoint)") else { - throw NetworkError.invalidURL - } - - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - if requiresAuth { - if let token = KeychainHelper.retrieve(forKey: "accessToken") { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - } - - if let body = body { - do { - request.httpBody = try JSONEncoder().encode(body) - } catch { - throw NetworkError.encodingError - } - } - - do { - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw NetworkError.unknown(NSError(domain: "APIService", code: -1)) - } - - switch httpResponse.statusCode { - case 200...299: - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(T.self, from: data) - } catch let decodingError { - if let jsonString = String(data: data, encoding: .utf8) { - print("Decoding Error - Response JSON: \(jsonString)") - } - print("Decoding Error Details: \(decodingError)") - throw NetworkError.unknown(decodingError) - } - case 401: - throw NetworkError.unauthorized - case 403: - throw NetworkError.forbidden - case 404: - throw NetworkError.notFound - case 500...599: - throw NetworkError.serverError(httpResponse.statusCode) - default: - throw NetworkError.serverError(httpResponse.statusCode) - } - } catch let error as NetworkError { - throw error - } catch { - throw NetworkError.unknown(error) - } - } -} - -enum HTTPMethod: String { - case get = "GET" - case post = "POST" - case put = "PUT" - case delete = "DELETE" - case patch = "PATCH" -} diff --git a/frontend/MusIQ/Services/AuthService.swift b/frontend/MusIQ/Services/AuthService.swift deleted file mode 100644 index 4d54585..0000000 --- a/frontend/MusIQ/Services/AuthService.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation - -class AuthService { - private let apiService = APIService.shared - - func login(request: LoginRequest) async throws -> AuthToken { - do { - let response: APIResponse = try await apiService.request( - endpoint: "/auth/login", - method: .post, - body: request, - requiresAuth: false - ) - - guard response.success, let data = response.data else { - if let error = response.error { - throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 401, userInfo: [NSLocalizedDescriptionKey: error.message])) - } - throw NetworkError.unauthorized - } - - return data - } catch let error as NetworkError { - throw error - } catch { - throw NetworkError.unknown(error) - } - } - - func signup(request: SignupRequest) async throws -> AuthToken { - do { - let response: APIResponse = try await apiService.request( - endpoint: "/auth/signup", - method: .post, - body: request, - requiresAuth: false - ) - - guard response.success, let data = response.data else { - if let error = response.error { - throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 400, userInfo: [NSLocalizedDescriptionKey: error.message])) - } - throw NetworkError.unknown(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Signup failed"])) - } - - return data - } catch let error as NetworkError { - throw error - } catch { - throw NetworkError.unknown(error) - } - } - - func refreshToken(request: RefreshTokenRequest) async throws -> AuthToken { - let response: APIResponse = try await apiService.request( - endpoint: "/auth/refresh", - method: .post, - body: request, - requiresAuth: false - ) - - guard response.success, let data = response.data else { - throw NetworkError.unauthorized - } - - return data - } - - func getCurrentUser() async throws -> User { - let response: APIResponse = try await apiService.request( - endpoint: "/profile", - method: .get - ) - - guard response.success, let data = response.data else { - throw NetworkError.unauthorized - } - - return data - } - - func logout() async throws { - _ = try await apiService.request( - endpoint: "/auth/logout", - method: .post, - body: EmptyBody(), - requiresAuth: true - ) as APIResponse - - KeychainHelper.clearAll() - } -} - -struct EmptyBody: Codable {} -struct EmptyResponse: Codable {} diff --git a/frontend/MusIQ/Services/MusicService.swift b/frontend/MusIQ/Services/MusicService.swift deleted file mode 100644 index 7b98b67..0000000 --- a/frontend/MusIQ/Services/MusicService.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -class MusicService { - private let apiService = APIService.shared - - func getFeed(filter: String, page: Int = 1, limit: Int = 20) async throws -> [MusicItem] { - let endpoint = "/music/feed?filter=\(filter)&page=\(page)&limit=\(limit)" - let response: APIResponse<[MusicItem]> = try await apiService.request( - endpoint: endpoint, - method: .get - ) - - guard response.success, let data = response.data else { - return [] - } - - return data - } - - func getMusicItem(id: String) async throws -> MusicItem { - let response: APIResponse = try await apiService.request( - endpoint: "/music/\(id)", - method: .get - ) - - guard response.success, let data = response.data else { - throw NetworkError.notFound - } - - return data - } - - func search(query: String) async throws -> [MusicItem] { - let endpoint = "/music/search?q=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" - let response: APIResponse<[MusicItem]> = try await apiService.request( - endpoint: endpoint, - method: .get - ) - - guard response.success, let data = response.data else { - return [] - } - - return data - } -} diff --git a/frontend/MusIQ/Services/NotificationService.swift b/frontend/MusIQ/Services/NotificationService.swift deleted file mode 100644 index f9f3684..0000000 --- a/frontend/MusIQ/Services/NotificationService.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -class NotificationService { - private let apiService = APIService.shared - - func getNotifications() async throws -> [AppNotification] { - let response: APIResponse<[AppNotification]> = try await apiService.request( - endpoint: "/notifications", - method: .get - ) - - guard response.success, let data = response.data else { - return [] - } - - return data - } - - func markAsRead(notificationId: String) async throws { - _ = try await apiService.request( - endpoint: "/notifications/\(notificationId)/read", - method: .put, - body: EmptyBody(), - requiresAuth: true - ) as APIResponse - } - - func markAllAsRead() async throws { - _ = try await apiService.request( - endpoint: "/notifications/read-all", - method: .put, - body: EmptyBody(), - requiresAuth: true - ) as APIResponse - } -} diff --git a/frontend/MusIQ/Services/OAuthService.swift b/frontend/MusIQ/Services/OAuthService.swift deleted file mode 100644 index fb112bc..0000000 --- a/frontend/MusIQ/Services/OAuthService.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import AuthenticationServices -#if canImport(AppAuth) -import AppAuth -#endif - -enum OAuthProviderType { - case apple - case google - - var rawValue: String { - switch self { - case .apple: return "apple" - case .google: return "google" - } - } -} - -class OAuthService { - private let authService: AuthService - - init(authService: AuthService = AuthService()) { - self.authService = authService - } - - func signInWithGoogle() async throws -> AuthToken { - throw NetworkError.unauthorized - } - - func handleOAuthCallback(authorizationCode: String, provider: OAuthProviderType, idToken: String? = nil, email: String? = nil, name: String? = nil, userIdentifier: String? = nil) async throws -> AuthToken { - - var requestBody: [String: Any] = [ - "token": authorizationCode - ] - - if let idToken = idToken { - requestBody["idToken"] = idToken - } - - if let email = email { - requestBody["email"] = email - } - - if let name = name { - requestBody["name"] = name - } - - if let userIdentifier = userIdentifier { - requestBody["userIdentifier"] = userIdentifier - } - - struct OAuthRequestBody: Codable { - let token: String - let idToken: String? - let email: String? - let name: String? - let userIdentifier: String? - } - - let request = OAuthRequestBody( - token: authorizationCode, - idToken: idToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - - let response: APIResponse = try await APIService.shared.request( - endpoint: "/auth/oauth/\(provider.rawValue)", - method: .post, - body: request, - requiresAuth: false - ) - - guard response.success, let data = response.data else { - throw NetworkError.unauthorized - } - - return data - } -} diff --git a/frontend/MusIQ/Services/ProfileService.swift b/frontend/MusIQ/Services/ProfileService.swift deleted file mode 100644 index 05a5f33..0000000 --- a/frontend/MusIQ/Services/ProfileService.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -class ProfileService { - private let apiService = APIService.shared - - func getProfile() async throws -> UserProfile { - let response: APIResponse = try await apiService.request( - endpoint: "/profile", - method: .get - ) - - guard response.success, let data = response.data else { - throw NetworkError.unauthorized - } - - return data - } - - func getTasteProfile() async throws -> TasteProfileResponse { - let response: APIResponse = try await apiService.request( - endpoint: "/profile/taste", - method: .get - ) - - guard response.success, let data = response.data else { - throw NetworkError.unauthorized - } - - return data - } - - func updateProfile(_ user: User) async throws -> User { - let response: APIResponse = try await apiService.request( - endpoint: "/profile", - method: .put, - body: user - ) - - guard response.success, let data = response.data else { - throw NetworkError.unknown(NSError(domain: "ProfileService", code: -1)) - } - - return data - } -} diff --git a/frontend/MusIQ/Services/RankingService.swift b/frontend/MusIQ/Services/RankingService.swift deleted file mode 100644 index e7d4118..0000000 --- a/frontend/MusIQ/Services/RankingService.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -class RankingService { - private let apiService = APIService.shared - - func getRankings(type: String, timeframe: String = "all_time") async throws -> [RankingItem] { - let endpoint = "/rankings/\(type)?timeframe=\(timeframe)" - let response: APIResponse<[RankingItem]> = try await apiService.request( - endpoint: endpoint, - method: .get - ) - - guard response.success, let data = response.data else { - - return [] - } - - return data - } -} diff --git a/frontend/MusIQ/Services/RatingService.swift b/frontend/MusIQ/Services/RatingService.swift deleted file mode 100644 index 15bd9dc..0000000 --- a/frontend/MusIQ/Services/RatingService.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -class RatingService { - private let apiService = APIService.shared - - func submitRating(request: CreateRatingRequest) async throws -> RatingResponse { - let response: APIResponse = try await apiService.request( - endpoint: "/ratings", - method: .post, - body: request - ) - - guard response.success, let data = response.data else { - throw NetworkError.unknown(NSError(domain: "RatingService", code: -1)) - } - - return data - } - - func getRatings(for musicItemId: String) async throws -> [Rating] { - let response: APIResponse<[Rating]> = try await apiService.request( - endpoint: "/ratings/\(musicItemId)", - method: .get - ) - - guard response.success, let data = response.data else { - return [] - } - - return data - } - - func getUserRatings(userId: String) async throws -> [Rating] { - let response: APIResponse<[Rating]> = try await apiService.request( - endpoint: "/ratings/user/\(userId)", - method: .get - ) - - guard response.success, let data = response.data else { - return [] - } - - return data - } -} diff --git a/frontend/MusIQ/Services/SocialService.swift b/frontend/MusIQ/Services/SocialService.swift deleted file mode 100644 index 9ef267a..0000000 --- a/frontend/MusIQ/Services/SocialService.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation - -class SocialService { - private let apiService = APIService.shared - - func getFriends() async throws -> [Friend] { - let response: APIResponse<[Friend]> = try await apiService.request( - endpoint: "/social/friends", - method: .get - ) - - guard response.success, let data = response.data else { - return [] - } - - return data - } - - func follow(userId: String) async throws { - _ = try await apiService.request( - endpoint: "/social/follow/\(userId)", - method: .post, - body: EmptyBody(), - requiresAuth: true - ) as APIResponse - } - - func getCompatibility(userId: String) async throws -> Int { - let response: APIResponse = try await apiService.request( - endpoint: "/social/compatibility/\(userId)", - method: .get - ) - - guard response.success, let data = response.data else { - return 0 - } - - return data.compatibility - } - - func compareTaste(userId: String) async throws -> TasteComparison { - let response: APIResponse = try await apiService.request( - endpoint: "/social/compare/\(userId)", - method: .get - ) - - guard response.success, let data = response.data else { - throw NetworkError.unknown(NSError(domain: "SocialService", code: -1)) - } - - return data - } -} - -struct CompatibilityResponse: Codable { - let compatibility: Int -} - -struct TasteComparison: Codable { - let compatibility: Int - let sharedArtists: Int - let sharedGenres: [String] -} diff --git a/frontend/MusIQ/Theme/AppColors.swift b/frontend/MusIQ/Theme/AppColors.swift deleted file mode 100644 index 7ee6a07..0000000 --- a/frontend/MusIQ/Theme/AppColors.swift +++ /dev/null @@ -1,51 +0,0 @@ -import SwiftUI - -struct AppColors { - static let background = Color(hex: "#F5F8FC") - static let cardBackground = Color(hex: "#FFFFFF") - static let secondaryBackground = Color(hex: "#EAF1F8") - - static let primary = Color(hex: "#35516D") - static let secondary = Color(hex: "#7A93AC") - - static let accent = Color(hex: "#35516D") - static let accentLight = Color(hex: "#D0DEEC") - - static let textPrimary = Color(hex: "#0F2A44") - static let textSecondary = Color(hex: "#7A93AC") - - static let border = Color(hex: "#D6E0EB") - static let borderLight = Color(hex: "#D0DEEC") - - static let notificationImpact = Color(hex: "#35516D") - static let notificationBadge = Color(hex: "#7A93AC") - static let notificationSocial = Color(hex: "#35516D") - static let notificationTrending = Color(hex: "#7A93AC") -} - -extension Color { - init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 3: - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (a, r, g, b) = (1, 1, 1, 0) - } - - self.init( - .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 - ) - } -} diff --git a/frontend/MusIQ/Theme/AppGradients.swift b/frontend/MusIQ/Theme/AppGradients.swift deleted file mode 100644 index 860b1bc..0000000 --- a/frontend/MusIQ/Theme/AppGradients.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI - -struct AppGradients { - static let primary = AppColors.primary - static let background = AppColors.background - static let card = AppColors.cardBackground - static let accent = AppColors.accent - static let splash = AppColors.background -} - -struct GradientBackground: ViewModifier { - let color: Color - - func body(content: Content) -> some View { - content - .background(color) - } -} - -extension View { - func gradientBackground(_ color: Color = AppColors.background) -> some View { - modifier(GradientBackground(color: color)) - } -} diff --git a/frontend/MusIQ/Theme/AppStyles.swift b/frontend/MusIQ/Theme/AppStyles.swift deleted file mode 100644 index 65e5b45..0000000 --- a/frontend/MusIQ/Theme/AppStyles.swift +++ /dev/null @@ -1,84 +0,0 @@ -import SwiftUI - -struct AppStyles { - - static let cornerRadiusSmall: CGFloat = 12 - static let cornerRadiusMedium: CGFloat = 16 - static let cornerRadiusLarge: CGFloat = 24 - - static let spacingSmall: CGFloat = 8 - static let spacingMedium: CGFloat = 16 - static let spacingLarge: CGFloat = 24 - - static let paddingSmall: CGFloat = 12 - static let paddingMedium: CGFloat = 16 - static let paddingLarge: CGFloat = 24 - - static let shadowColor = Color.black.opacity(0.1) - static let shadowRadius: CGFloat = 8 - static let shadowOffset = CGSize(width: 0, height: 4) -} - -struct CardStyle: ViewModifier { - func body(content: Content) -> some View { - content - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .shadow( - color: AppStyles.shadowColor, - radius: AppStyles.shadowRadius, - x: AppStyles.shadowOffset.width, - y: AppStyles.shadowOffset.height - ) - } -} - -struct GradientButtonStyle: ButtonStyle { - let isEnabled: Bool - - init(isEnabled: Bool = true) { - self.isEnabled = isEnabled - } - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, AppStyles.paddingMedium) - .padding(.vertical, AppStyles.paddingSmall) - .background(isEnabled ? AppColors.primary : AppColors.secondaryBackground) - .foregroundColor(isEnabled ? .white : AppColors.textSecondary) - .cornerRadius(AppStyles.cornerRadiusMedium) - .scaleEffect(configuration.isPressed ? 0.95 : 1.0) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) - } -} - -struct SecondaryButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, AppStyles.paddingMedium) - .padding(.vertical, AppStyles.paddingSmall) - .background(AppColors.secondaryBackground) - .foregroundColor(AppColors.textSecondary) - .cornerRadius(AppStyles.cornerRadiusMedium) - .scaleEffect(configuration.isPressed ? 0.95 : 1.0) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) - } -} - -extension View { - func cardStyle() -> some View { - modifier(CardStyle()) - } - - func gradientButton(isEnabled: Bool = true) -> some View { - buttonStyle(GradientButtonStyle(isEnabled: isEnabled)) - } - - func secondaryButton() -> some View { - buttonStyle(SecondaryButtonStyle()) - } -} diff --git a/frontend/MusIQ/Utilities/AnimationHelpers.swift b/frontend/MusIQ/Utilities/AnimationHelpers.swift deleted file mode 100644 index 39eeb1f..0000000 --- a/frontend/MusIQ/Utilities/AnimationHelpers.swift +++ /dev/null @@ -1,63 +0,0 @@ -import SwiftUI - -extension View { - func fadeIn(delay: Double = 0) -> some View { - self.modifier(FadeInModifier(delay: delay)) - } - - func slideIn(from edge: Edge, delay: Double = 0) -> some View { - self.modifier(SlideInModifier(edge: edge, delay: delay)) - } - - func scaleIn(delay: Double = 0) -> some View { - self.modifier(ScaleInModifier(delay: delay)) - } -} - -struct FadeInModifier: ViewModifier { - let delay: Double - @State private var opacity: Double = 0 - - func body(content: Content) -> some View { - content - .opacity(opacity) - .onAppear { - withAnimation(.easeInOut(duration: 0.5).delay(delay)) { - opacity = 1 - } - } - } -} - -struct SlideInModifier: ViewModifier { - let edge: Edge - let delay: Double - @State private var offset: CGFloat = 0 - - func body(content: Content) -> some View { - content - .offset(x: edge == .leading ? -offset : edge == .trailing ? offset : 0, - y: edge == .top ? -offset : edge == .bottom ? offset : 0) - .onAppear { - offset = 100 - withAnimation(.spring(response: 0.6, dampingFraction: 0.8).delay(delay)) { - offset = 0 - } - } - } -} - -struct ScaleInModifier: ViewModifier { - let delay: Double - @State private var scale: CGFloat = 0 - - func body(content: Content) -> some View { - content - .scaleEffect(scale) - .onAppear { - withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(delay)) { - scale = 1 - } - } - } -} diff --git a/frontend/MusIQ/Utilities/Extensions.swift b/frontend/MusIQ/Utilities/Extensions.swift deleted file mode 100644 index 8882cd2..0000000 --- a/frontend/MusIQ/Utilities/Extensions.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import SwiftUI - -extension Int { - func formatted() -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter.string(from: NSNumber(value: self)) ?? "\(self)" - } -} - -extension Double { - func formatted() -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - return formatter.string(from: NSNumber(value: self)) ?? "\(self)" - } -} - -extension Date { - func timeAgo() -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: self, relativeTo: Date()) - } -} - -extension View { - func hideKeyboard() { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } -} diff --git a/frontend/MusIQ/Utilities/ImageLoader.swift b/frontend/MusIQ/Utilities/ImageLoader.swift deleted file mode 100644 index a74c943..0000000 --- a/frontend/MusIQ/Utilities/ImageLoader.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI - -struct MusicAsyncImage: View { - let url: String - let placeholder: String - - init(url: String, placeholder: String = "music.note") { - self.url = url - self.placeholder = placeholder - } - - var body: some View { - AsyncImage(url: URL(string: url)) { phase in - switch phase { - case .empty: - ZStack { - Rectangle() - .fill(AppColors.secondaryBackground) - ProgressView() - .tint(AppColors.primary) - } - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - case .failure: - ZStack { - Rectangle() - .fill(AppColors.secondaryBackground) - Image(systemName: placeholder) - .font(.system(size: 24)) - .foregroundColor(AppColors.textSecondary) - } - @unknown default: - EmptyView() - } - } - } -} diff --git a/frontend/MusIQ/Utilities/KeychainHelper.swift b/frontend/MusIQ/Utilities/KeychainHelper.swift deleted file mode 100644 index 2f88caf..0000000 --- a/frontend/MusIQ/Utilities/KeychainHelper.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import Security - -class KeychainHelper { - static let service = "com.musiq.tokens" - - static func store(token: String, forKey key: String) { - let data = token.data(using: .utf8)! - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecValueData as String: data - ] - - SecItemDelete(query as CFDictionary) - - SecItemAdd(query as CFDictionary, nil) - } - - static func retrieve(forKey key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let data = result as? Data, - let token = String(data: data, encoding: .utf8) else { - return nil - } - - return token - } - - static func delete(forKey key: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - - SecItemDelete(query as CFDictionary) - } - - static func clearAll() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service - ] - - SecItemDelete(query as CFDictionary) - } -} diff --git a/frontend/MusIQ/Utilities/NetworkError.swift b/frontend/MusIQ/Utilities/NetworkError.swift deleted file mode 100644 index 241460b..0000000 --- a/frontend/MusIQ/Utilities/NetworkError.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -enum NetworkError: LocalizedError { - case invalidURL - case noData - case decodingError - case encodingError - case serverError(Int) - case unauthorized - case forbidden - case notFound - case networkUnavailable - case unknown(Error) - - var errorDescription: String? { - switch self { - case .invalidURL: - return "Invalid URL" - case .noData: - return "No data received" - case .decodingError: - return "Failed to decode response" - case .encodingError: - return "Failed to encode request" - case .serverError(let code): - return "Server error: \(code)" - case .unauthorized: - return "Unauthorized. Please log in again." - case .forbidden: - return "Access forbidden" - case .notFound: - return "Resource not found" - case .networkUnavailable: - return "Network unavailable. Please check your connection." - case .unknown(let error): - return error.localizedDescription - } - } -} diff --git a/frontend/MusIQ/ViewModels/AppState.swift b/frontend/MusIQ/ViewModels/AppState.swift deleted file mode 100644 index 2b00f41..0000000 --- a/frontend/MusIQ/ViewModels/AppState.swift +++ /dev/null @@ -1,89 +0,0 @@ -import Foundation -import SwiftUI -import Combine - -enum AppScreen { - case splash - case onboarding - case authentication - case main -} - -class AppState: ObservableObject { - @Published var currentScreen: AppScreen = .splash - @Published var hasCompletedOnboarding: Bool = false - @Published var isAuthenticated: Bool = false - @Published var currentUser: User? - - private let userDefaults = UserDefaults.standard - private let onboardingKey = "hasCompletedOnboarding" - private let authKey = "isAuthenticated" - - @MainActor - init() { - hasCompletedOnboarding = userDefaults.bool(forKey: onboardingKey) - - let hasAccessToken = KeychainHelper.retrieve(forKey: "accessToken") != nil - isAuthenticated = hasAccessToken - - if !hasAccessToken { - userDefaults.set(false, forKey: authKey) - } - - if !hasCompletedOnboarding { - currentScreen = .onboarding - } else if !isAuthenticated { - currentScreen = .authentication - } else { - Task { - await verifyAuthentication() - } - currentScreen = .main - } - } - - @MainActor - private func verifyAuthentication() async { - let authService = AuthService() - do { - let user = try await authService.getCurrentUser() - self.currentUser = user - self.isAuthenticated = true - userDefaults.set(true, forKey: authKey) - } catch { - self.isAuthenticated = false - self.currentUser = nil - userDefaults.set(false, forKey: authKey) - KeychainHelper.clearAll() - self.currentScreen = .authentication - } - } - - @MainActor - func completeOnboarding() { - hasCompletedOnboarding = true - userDefaults.set(true, forKey: onboardingKey) - currentScreen = .authentication - } - - @MainActor - func authenticate(user: User) { - isAuthenticated = true - currentUser = user - userDefaults.set(true, forKey: authKey) - currentScreen = .main - } - - @MainActor - func logout() { - isAuthenticated = false - currentUser = nil - userDefaults.set(false, forKey: authKey) - KeychainHelper.clearAll() - currentScreen = .authentication - } -} - -extension AppState { - static var shared: AppState = AppState() -} diff --git a/frontend/MusIQ/ViewModels/AuthViewModel.swift b/frontend/MusIQ/ViewModels/AuthViewModel.swift deleted file mode 100644 index 03df026..0000000 --- a/frontend/MusIQ/ViewModels/AuthViewModel.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Foundation -import SwiftUI -import Combine - -class AuthViewModel: ObservableObject { - @Published var username: String = "" - @Published var password: String = "" - @Published var confirmPassword: String = "" - @Published var isLoading: Bool = false - @Published var errorMessage: String? - @Published var isAuthenticated: Bool = false - @Published var passwordErrors: [String] = [] - - private let authService: AuthService - - init(authService: AuthService = AuthService()) { - self.authService = authService - } - - func validatePassword(_ password: String) -> [String] { - var errors: [String] = [] - - if password.count < 8 || password.count > 128 { - errors.append("Password must be between 8 and 128 characters") - } - if !password.contains(where: { $0.isLowercase }) { - errors.append("Password must contain at least one lowercase letter") - } - if !password.contains(where: { $0.isUppercase }) { - errors.append("Password must contain at least one uppercase letter") - } - if !password.contains(where: { $0.isNumber }) { - errors.append("Password must contain at least one number") - } - let specialChars = CharacterSet(charactersIn: "@$!%*?&") - if !password.unicodeScalars.contains(where: { specialChars.contains($0) }) { - errors.append("Password must contain at least one special character (@$!%*?&)") - } - - return errors - } - - func login() async { - isLoading = true - errorMessage = nil - - guard !username.isEmpty, !password.isEmpty else { - errorMessage = "Username and password are required" - isLoading = false - return - } - - do { - let request = LoginRequest(username: username, password: password) - let token = try await authService.login(request: request) - - KeychainHelper.store(token: token.accessToken, forKey: "accessToken") - KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") - - let user = try await authService.getCurrentUser() - isAuthenticated = true - - let appState = getAppState() - appState.authenticate(user: user) - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false - } - - func signup() async { - isLoading = true - errorMessage = nil - passwordErrors = [] - - if username.count < 3 || username.count > 30 { - errorMessage = "Username must be between 3 and 30 characters" - isLoading = false - return - } - - if !username.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) { - errorMessage = "Username can only contain letters, numbers, and underscores" - isLoading = false - return - } - - if password != confirmPassword { - errorMessage = "Passwords do not match" - isLoading = false - return - } - - let pwdErrors = validatePassword(password) - if !pwdErrors.isEmpty { - passwordErrors = pwdErrors - errorMessage = "Please fix password requirements" - isLoading = false - return - } - - do { - let request = SignupRequest(username: username, password: password, confirmPassword: confirmPassword) - let token = try await authService.signup(request: request) - - KeychainHelper.store(token: token.accessToken, forKey: "accessToken") - KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") - - let user = try await authService.getCurrentUser() - isAuthenticated = true - - let appState = getAppState() - appState.authenticate(user: user) - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false - } - - func loginWithApple(authorizationCode: String, identityToken: String?, email: String? = nil, name: String? = nil, userIdentifier: String? = nil) async { - isLoading = true - errorMessage = nil - - do { - let oauthService = OAuthService(authService: authService) - let token = try await oauthService.handleOAuthCallback( - authorizationCode: authorizationCode, - provider: .apple, - idToken: identityToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - - KeychainHelper.store(token: token.accessToken, forKey: "accessToken") - KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") - - let user = try await authService.getCurrentUser() - isAuthenticated = true - - let appState = getAppState() - appState.authenticate(user: user) - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false - } - - func loginWithGoogle(authorizationCode: String, idToken: String?) async { - isLoading = true - errorMessage = nil - - do { - let oauthService = OAuthService(authService: authService) - let token = try await oauthService.handleOAuthCallback( - authorizationCode: authorizationCode, - provider: .google, - idToken: idToken - ) - - KeychainHelper.store(token: token.accessToken, forKey: "accessToken") - KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") - - let user = try await authService.getCurrentUser() - isAuthenticated = true - - let appState = getAppState() - appState.authenticate(user: user) - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false - } - - - private func getAppState() -> AppState { - - return AppState.shared - } -} diff --git a/frontend/MusIQ/ViewModels/HomeFeedViewModel.swift b/frontend/MusIQ/ViewModels/HomeFeedViewModel.swift deleted file mode 100644 index e4d2237..0000000 --- a/frontend/MusIQ/ViewModels/HomeFeedViewModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import Combine -import SwiftUI - -@MainActor -class HomeFeedViewModel: ObservableObject { - @Published var feedItems: [MusicItem] = [] - @Published var isLoading: Bool = false - @Published var errorMessage: String? - @Published var selectedItem: MusicItem? - @Published var showRatingModal: Bool = false - - private let musicService: MusicService - - init(musicService: MusicService = MusicService()) { - self.musicService = musicService - } - - func loadFeed() async { - isLoading = true - errorMessage = nil - - do { - feedItems = try await musicService.getFeed(filter: "forYou") - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false - } - - func selectItemForRating(_ item: MusicItem) { - selectedItem = item - showRatingModal = true - } - - func refreshFeed() async { - await loadFeed() - } -} diff --git a/frontend/MusIQ/ViewModels/NotificationViewModel.swift b/frontend/MusIQ/ViewModels/NotificationViewModel.swift deleted file mode 100644 index 8547d15..0000000 --- a/frontend/MusIQ/ViewModels/NotificationViewModel.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -import Combine -import SwiftUI - -@MainActor -class NotificationViewModel: ObservableObject { - @Published var notifications: [AppNotification] = [] - @Published var isLoading: Bool = false - @Published var errorMessage: String? - - private let notificationService: NotificationService - - init(notificationService: NotificationService = NotificationService()) { - self.notificationService = notificationService - } - - func loadNotifications() async { - isLoading = true - errorMessage = nil - - do { - notifications = try await notificationService.getNotifications() - } catch { - errorMessage = error.localizedDescription - notifications = [] - } - - isLoading = false - } - - func markAsRead(_ notificationId: String) async { - do { - try await notificationService.markAsRead(notificationId: notificationId) - if let index = notifications.firstIndex(where: { $0.id == notificationId }) { - notifications[index] = AppNotification( - id: notifications[index].id, - userId: notifications[index].userId, - type: notifications[index].type, - title: notifications[index].title, - message: notifications[index].message, - read: true, - metadata: notifications[index].metadata, - createdAt: notifications[index].createdAt - ) - } - } catch { - errorMessage = error.localizedDescription - } - } - - func markAllAsRead() async { - do { - try await notificationService.markAllAsRead() - notifications = notifications.map { notification in - AppNotification( - id: notification.id, - userId: notification.userId, - type: notification.type, - title: notification.title, - message: notification.message, - read: true, - metadata: notification.metadata, - createdAt: notification.createdAt - ) - } - } catch { - errorMessage = error.localizedDescription - } - } - - func getNotificationColor(_ type: NotificationType) -> Color { - switch type { - case .impact: - return AppColors.notificationImpact - case .badge: - return AppColors.notificationBadge - case .social: - return AppColors.notificationSocial - case .trending: - return AppColors.notificationTrending - } - } -} diff --git a/frontend/MusIQ/ViewModels/RankingViewModel.swift b/frontend/MusIQ/ViewModels/RankingViewModel.swift deleted file mode 100644 index a61fa15..0000000 --- a/frontend/MusIQ/ViewModels/RankingViewModel.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import Combine -import SwiftUI - -enum RankingType: String, CaseIterable { - case albums = "albums" - case artists = "artists" - case songs = "songs" - - var displayName: String { - switch self { - case .albums: return "Albums" - case .artists: return "Artists" - case .songs: return "Songs" - } - } -} - -struct RankingItem: Identifiable, Codable { - let id: String - let rank: Int - let title: String - let artist: String - let imageUrl: String - let rating: Double - let ratingCount: Int - let isNew: Bool - let change: Int -} - -@MainActor -class RankingViewModel: ObservableObject { - @Published var activeType: RankingType = .albums - @Published var rankings: [RankingItem] = [] - @Published var isLoading: Bool = false - @Published var errorMessage: String? - - private let rankingService: RankingService - - init(rankingService: RankingService = RankingService()) { - self.rankingService = rankingService - } - - func loadRankings() async { - isLoading = true - errorMessage = nil - - do { - rankings = try await rankingService.getRankings(type: activeType.rawValue) - } catch { - errorMessage = error.localizedDescription - rankings = [] - } - - isLoading = false - } - - func setType(_ type: RankingType) { - activeType = type - Task { - await loadRankings() - } - } -} diff --git a/frontend/MusIQ/ViewModels/RatingViewModel.swift b/frontend/MusIQ/ViewModels/RatingViewModel.swift deleted file mode 100644 index 529213c..0000000 --- a/frontend/MusIQ/ViewModels/RatingViewModel.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import Combine -import SwiftUI - -@MainActor -class RatingViewModel: ObservableObject { - @Published var rating: Int = 0 - @Published var hoverRating: Int = 0 - @Published var selectedTags: Set = [] - @Published var isSubmitting: Bool = false - @Published var errorMessage: String? - - let availableTags = ["Lyrics", "Production", "Replay", "Emotional", "Innovative", "Classic"] - - private let ratingService: RatingService - - init(ratingService: RatingService = RatingService()) { - self.ratingService = ratingService - } - - func setRating(_ value: Int) { - rating = value - } - - func setHoverRating(_ value: Int) { - hoverRating = value - } - - func toggleTag(_ tag: String) { - if selectedTags.contains(tag) { - selectedTags.remove(tag) - } else { - selectedTags.insert(tag) - } - } - - func submitRating(for musicItemId: String) async -> Bool { - guard rating > 0 else { - errorMessage = "Please select a rating" - return false - } - - isSubmitting = true - errorMessage = nil - - do { - let request = CreateRatingRequest( - musicItemId: musicItemId, - rating: rating, - tags: Array(selectedTags) - ) - - _ = try await ratingService.submitRating(request: request) - - rating = 0 - hoverRating = 0 - selectedTags = [] - - isSubmitting = false - return true - } catch { - errorMessage = error.localizedDescription - isSubmitting = false - return false - } - } - - func reset() { - rating = 0 - hoverRating = 0 - selectedTags = [] - errorMessage = nil - } -} diff --git a/frontend/MusIQ/ViewModels/SocialViewModel.swift b/frontend/MusIQ/ViewModels/SocialViewModel.swift deleted file mode 100644 index ec8dde0..0000000 --- a/frontend/MusIQ/ViewModels/SocialViewModel.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import Combine -import SwiftUI - -@MainActor -class SocialViewModel: ObservableObject { - @Published var friends: [Friend] = [] - @Published var isLoading: Bool = false - @Published var errorMessage: String? - - private let socialService: SocialService - - init(socialService: SocialService = SocialService()) { - self.socialService = socialService - } - - func loadFriends() async { - isLoading = true - errorMessage = nil - - do { - friends = try await socialService.getFriends() - } catch { - errorMessage = error.localizedDescription - friends = [] - } - - isLoading = false - } - - func getCompatibilityColor(_ score: Int) -> Color { - if score >= 80 { - return AppColors.primary - } else if score >= 60 { - return AppColors.secondary - } else { - return AppColors.accent - } - } - - func getCompatibilityEmoji(_ score: Int) -> String { - return "" - } -} diff --git a/frontend/MusIQ/ViewModels/TasteProfileViewModel.swift b/frontend/MusIQ/ViewModels/TasteProfileViewModel.swift deleted file mode 100644 index 9c8a1dc..0000000 --- a/frontend/MusIQ/ViewModels/TasteProfileViewModel.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import Combine -import SwiftUI - -struct GenreData: Identifiable { - let id = UUID() - let name: String - let value: Int -} - -struct DecadeData: Identifiable { - let id = UUID() - let decade: String - let value: Int -} - -struct RadarData: Identifiable { - let id = UUID() - let category: String - let value: Int -} - -@MainActor -class TasteProfileViewModel: ObservableObject { - @Published var tasteScore: Int = 0 - @Published var totalRatings: Int = 0 - @Published var influence: Int = 0 - @Published var genreData: [GenreData] = [] - @Published var decadeData: [DecadeData] = [] - @Published var radarData: [RadarData] = [] - @Published var controversyAffinity: Int = 0 - @Published var isLoading: Bool = false - @Published var errorMessage: String? - - private let profileService: ProfileService - - init(profileService: ProfileService = ProfileService()) { - self.profileService = profileService - } - - func loadProfile() async { - isLoading = true - errorMessage = nil - - do { - let profile = try await profileService.getTasteProfile() - - tasteScore = profile.tasteScore - totalRatings = profile.totalRatings - influence = profile.influence - - genreData = profile.genreAffinity.map { GenreData(name: $0.key, value: $0.value) } - - decadeData = profile.decadePreference.map { DecadeData(decade: $0.key, value: $0.value) } - - radarData = profile.attributes.map { RadarData(category: $0.key, value: $0.value) } - - controversyAffinity = profile.controversyAffinity - } catch { - errorMessage = error.localizedDescription - genreData = [] - decadeData = [] - radarData = [] - } - - isLoading = false - } -} - -struct TasteProfileResponse: Codable { - let tasteScore: Int - let totalRatings: Int - let influence: Int - let genreAffinity: [String: Int] - let decadePreference: [String: Int] - let attributes: [String: Int] - let controversyAffinity: Int -} diff --git a/frontend/MusIQ/Views/Auth/AppleSignInButton.swift b/frontend/MusIQ/Views/Auth/AppleSignInButton.swift deleted file mode 100644 index 1c3842c..0000000 --- a/frontend/MusIQ/Views/Auth/AppleSignInButton.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI -import AuthenticationServices - -struct AppleSignInButton: View { - let onSuccess: (String, String?) -> Void - let onError: (Error) -> Void - - var body: some View { - SignInWithAppleButton( - onRequest: { request in - request.requestedScopes = [.fullName, .email] - }, - onCompletion: { result in - - } - ) - .signInWithAppleButtonStyle(.black) - .frame(height: 50) - .cornerRadius(AppStyles.cornerRadiusMedium) - } -} - -#Preview { - AppleSignInButton( - onSuccess: { _, _ in }, - onError: { _ in } - ) -} diff --git a/frontend/MusIQ/Views/Auth/GoogleSignInButton.swift b/frontend/MusIQ/Views/Auth/GoogleSignInButton.swift deleted file mode 100644 index 6c58397..0000000 --- a/frontend/MusIQ/Views/Auth/GoogleSignInButton.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI -#if canImport(AppAuth) -import AppAuth -#endif - -struct GoogleSignInButton: View { - let onSuccess: (String, String?) -> Void - let onError: (Error) -> Void - - var body: some View { - Button(action: { - Task { - await performGoogleSignIn() - } - }) { - HStack { - Image(systemName: "globe") - .font(.system(size: 18)) - Text("Continue with Google") - .font(.system(size: 16, weight: .medium)) - } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(red: 0.26, green: 0.52, blue: 0.96)) - .cornerRadius(AppStyles.cornerRadiusMedium) - } - } - - @MainActor - private func performGoogleSignIn() async { - - } -} - -#if canImport(AppAuth) -extension OIDAuthorizationService { - static func present(_ request: OIDAuthorizationRequest, presenting: UIViewController) async throws -> OIDAuthState { - return try await withCheckedThrowingContinuation { continuation in - let authFlow = OIDAuthState.authState(byPresenting: request, presenting: presenting) { authState, error in - if let error = error { - continuation.resume(throwing: error) - } else if let authState = authState { - continuation.resume(returning: authState) - } else { - continuation.resume(throwing: NSError(domain: "OAuth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown error"])) - } - } - } - } -} -#endif - -#Preview { - GoogleSignInButton( - onSuccess: { _, _ in }, - onError: { _ in } - ) -} diff --git a/frontend/MusIQ/Views/Auth/LoginView.swift b/frontend/MusIQ/Views/Auth/LoginView.swift deleted file mode 100644 index 0e958d3..0000000 --- a/frontend/MusIQ/Views/Auth/LoginView.swift +++ /dev/null @@ -1,100 +0,0 @@ -import SwiftUI - -struct LoginView: View { - @StateObject private var viewModel = AuthViewModel() - @ObservedObject var appState: AppState - - var body: some View { - ZStack { - AppColors.background - .ignoresSafeArea() - - VStack(spacing: 32) { - Spacer() - - ZStack { - Circle() - .fill(AppColors.primary) - .frame(width: 120, height: 120) - - Image(systemName: "music.note") - .font(.system(size: 60)) - .foregroundColor(.white) - } - .padding(.bottom, 16) - - Text("Welcome Back") - .font(.system(size: 32, weight: .bold)) - .foregroundColor(AppColors.textPrimary) - - VStack(spacing: 20) { - VStack(alignment: .leading, spacing: 8) { - Text("Username") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - - TextField("", text: $viewModel.username) - .textFieldStyle(PlainTextFieldStyle()) - .padding() - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .foregroundColor(AppColors.textPrimary) - .autocapitalization(.none) - } - - VStack(alignment: .leading, spacing: 8) { - Text("Password") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - - SecureField("", text: $viewModel.password) - .textFieldStyle(PlainTextFieldStyle()) - .padding() - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .foregroundColor(AppColors.textPrimary) - } - - if let error = viewModel.errorMessage { - Text(error) - .font(.system(size: 14)) - .foregroundColor(AppColors.accent) - .frame(maxWidth: .infinity, alignment: .leading) - } - - Button(action: { - Task { - await viewModel.login() - } - }) { - if viewModel.isLoading { - ProgressView() - .tint(.white) - } else { - Text("Log In") - .font(.system(size: 16, weight: .semibold)) - } - } - .gradientButton(isEnabled: !viewModel.isLoading) - .disabled(viewModel.isLoading) - } - .padding(.horizontal, AppStyles.paddingLarge) - - - Spacer() - } - } - } -} - -#Preview { - LoginView(appState: AppState()) -} diff --git a/frontend/MusIQ/Views/Auth/OAuthCallbackHandler.swift b/frontend/MusIQ/Views/Auth/OAuthCallbackHandler.swift deleted file mode 100644 index 78d4b7d..0000000 --- a/frontend/MusIQ/Views/Auth/OAuthCallbackHandler.swift +++ /dev/null @@ -1,80 +0,0 @@ -import SwiftUI -import Combine - -struct OAuthCallbackHandler: ViewModifier { - @ObservedObject var viewModel: AuthViewModel - @State private var cancellables = Set() - - func body(content: Content) -> some View { - content - .onAppear { - - NotificationCenter.default.publisher(for: NSNotification.Name("OAuthCallback")) - .sink { notification in - guard let userInfo = notification.userInfo, - let providerString = userInfo["provider"] as? String, - let code = userInfo["code"] as? String else { - return - } - - let idToken = userInfo["idToken"] as? String - - Task { - switch providerString { - case "apple": - let email = userInfo["email"] as? String - let name = userInfo["name"] as? String - let userIdentifier = userInfo["userIdentifier"] as? String - await viewModel.loginWithApple( - authorizationCode: code, - identityToken: idToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - case "google": - let email = userInfo["email"] as? String - let name = userInfo["name"] as? String - await viewModel.loginWithGoogle( - authorizationCode: code, - idToken: idToken - ) - default: - break - } - } - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: NSNotification.Name("AppleSignInSuccess")) - .sink { notification in - guard let userInfo = notification.userInfo, - let code = userInfo["code"] as? String else { - return - } - - let idToken = userInfo["idToken"] as? String - let email = userInfo["email"] as? String - let name = userInfo["name"] as? String - let userIdentifier = userInfo["userIdentifier"] as? String - - Task { - await viewModel.loginWithApple( - authorizationCode: code, - identityToken: idToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - } - } - .store(in: &cancellables) - } - } -} - -extension View { - func handleOAuthCallbacks(viewModel: AuthViewModel) -> some View { - modifier(OAuthCallbackHandler(viewModel: viewModel)) - } -} diff --git a/frontend/MusIQ/Views/Auth/SignupView.swift b/frontend/MusIQ/Views/Auth/SignupView.swift deleted file mode 100644 index 37a0337..0000000 --- a/frontend/MusIQ/Views/Auth/SignupView.swift +++ /dev/null @@ -1,151 +0,0 @@ -import SwiftUI - -struct SignupView: View { - @StateObject private var viewModel = AuthViewModel() - @ObservedObject var appState: AppState - - var body: some View { - ZStack { - AppColors.background - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 32) { - - ZStack { - Circle() - .fill(AppColors.primary) - .frame(width: 120, height: 120) - - Image(systemName: "music.note") - .font(.system(size: 60)) - .foregroundColor(.white) - } - .padding(.top, 40) - - Text("Create Account") - .font(.system(size: 32, weight: .bold)) - .foregroundColor(AppColors.textPrimary) - - VStack(spacing: 20) { - VStack(alignment: .leading, spacing: 8) { - Text("Username") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - - TextField("", text: $viewModel.username) - .textFieldStyle(PlainTextFieldStyle()) - .padding() - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .foregroundColor(AppColors.textPrimary) - .autocapitalization(.none) - - Text("3-30 characters, letters, numbers, and underscores only") - .font(.system(size: 11)) - .foregroundColor(AppColors.textSecondary) - } - - VStack(alignment: .leading, spacing: 8) { - Text("Password") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - - SecureField("", text: $viewModel.password) - .textFieldStyle(PlainTextFieldStyle()) - .padding() - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .foregroundColor(AppColors.textPrimary) - .onChange(of: viewModel.password) { newValue in - viewModel.passwordErrors = viewModel.validatePassword(newValue) - } - - if !viewModel.passwordErrors.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.passwordErrors, id: \.self) { error in - Text("• \(error)") - .font(.system(size: 11)) - .foregroundColor(AppColors.accent) - } - } - } else if !viewModel.password.isEmpty { - Text("✓ Password meets all requirements") - .font(.system(size: 11)) - .foregroundColor(.green) - } - } - - VStack(alignment: .leading, spacing: 8) { - Text("Confirm Password") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - - SecureField("", text: $viewModel.confirmPassword) - .textFieldStyle(PlainTextFieldStyle()) - .padding() - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .foregroundColor(AppColors.textPrimary) - - if !viewModel.confirmPassword.isEmpty { - if viewModel.password == viewModel.confirmPassword { - Text("✓ Passwords match") - .font(.system(size: 11)) - .foregroundColor(.green) - } else { - Text("Passwords do not match") - .font(.system(size: 11)) - .foregroundColor(AppColors.accent) - } - } - } - - if let error = viewModel.errorMessage { - Text(error) - .font(.system(size: 14)) - .foregroundColor(AppColors.accent) - .frame(maxWidth: .infinity, alignment: .leading) - } - - Button(action: { - Task { - await viewModel.signup() - } - }) { - if viewModel.isLoading { - ProgressView() - .tint(.white) - } else { - Text("Sign Up") - .font(.system(size: 16, weight: .semibold)) - } - } - .gradientButton(isEnabled: !viewModel.isLoading) - .disabled(viewModel.isLoading) - } - .padding(.horizontal, AppStyles.paddingLarge) - - Spacer() - .frame(height: 40) - } - } - } - } -} - -#Preview { - SignupView(appState: AppState()) -} diff --git a/frontend/MusIQ/Views/Auth/SpotifySignInButton.swift b/frontend/MusIQ/Views/Auth/SpotifySignInButton.swift deleted file mode 100644 index 3456c07..0000000 --- a/frontend/MusIQ/Views/Auth/SpotifySignInButton.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI -#if canImport(AppAuth) -import AppAuth -#endif - -struct SpotifySignInButton: View { - let onSuccess: (String) -> Void - let onError: (Error) -> Void - - var body: some View { - Button(action: { - Task { - await performSpotifySignIn() - } - }) { - HStack { - Image(systemName: "music.note") - .font(.system(size: 18)) - Text("Continue with Spotify") - .font(.system(size: 16, weight: .medium)) - } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(red: 0.12, green: 0.73, blue: 0.33)) - .cornerRadius(AppStyles.cornerRadiusMedium) - } - } - - @MainActor - private func performSpotifySignIn() async { - - } -} - -#Preview { - SpotifySignInButton( - onSuccess: { _ in }, - onError: { _ in } - ) -} diff --git a/frontend/MusIQ/Views/FeedCardView.swift b/frontend/MusIQ/Views/FeedCardView.swift deleted file mode 100644 index c88ddf1..0000000 --- a/frontend/MusIQ/Views/FeedCardView.swift +++ /dev/null @@ -1,152 +0,0 @@ -import SwiftUI - -struct FeedCardView: View { - let item: MusicItem - let onRate: () -> Void - let onFavorite: () -> Void - let onComment: () -> Void - - @State private var isFavorited = false - - private var iconName: String { - switch item.type { - case .album: - return "opticaldisc.fill" - case .song: - return "music.note" - case .artist: - return "person.fill" - } - } - - private var iconColor: Color { - switch item.type { - case .album: - return AppColors.primary - case .song: - return AppColors.secondary - case .artist: - return AppColors.accent - } - } - - var body: some View { - HStack(spacing: 16) { - ZStack(alignment: .topTrailing) { - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .fill(AppColors.secondaryBackground) - .frame(width: 96, height: 96) - .overlay( - Image(systemName: iconName) - .font(.system(size: 40)) - .foregroundColor(iconColor) - ) - - if item.trending == true { - ZStack { - Circle() - .fill(AppColors.accent) - .frame(width: 24, height: 24) - - Image(systemName: "arrow.up") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(.white) - } - .offset(x: 8, y: -8) - } - } - - VStack(alignment: .leading, spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text(item.title) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(AppColors.textPrimary) - .lineLimit(1) - - Text(item.artist) - .font(.system(size: 14)) - .foregroundColor(AppColors.textSecondary) - .lineLimit(1) - } - - HStack(spacing: 8) { - HStack(spacing: 4) { - Image(systemName: "star.fill") - .font(.system(size: 14)) - .foregroundColor(AppColors.secondary) - - Text(String(format: "%.1f", item.rating)) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textPrimary) - } - - Text("(\(item.ratingCount.formatted()))") - .font(.system(size: 12)) - .foregroundColor(AppColors.textSecondary) - - if let change = item.trendingChange, item.trending == true { - HStack(spacing: 4) { - Image(systemName: "arrow.up") - .font(.system(size: 10)) - .foregroundColor(AppColors.primary) - - Text("+\(change)") - .font(.system(size: 12)) - .foregroundColor(AppColors.primary) - } - } - } - - HStack(spacing: 8) { - Button(action: { - isFavorited.toggle() - onFavorite() - }) { - Image(systemName: isFavorited ? "heart.fill" : "heart") - .font(.system(size: 16)) - .foregroundColor( - isFavorited ? - AppColors.accent : - AppColors.textSecondary - ) - .frame(width: 36, height: 36) - .background( - isFavorited ? - AppColors.accentLight : - AppColors.secondaryBackground - ) - .cornerRadius(AppStyles.cornerRadiusSmall) - } - - Button(action: onRate) { - HStack(spacing: 4) { - Image(systemName: "star.fill") - .font(.system(size: 14)) - - Text("Rate") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(AppColors.primary) - .cornerRadius(AppStyles.cornerRadiusSmall) - } - - Button(action: onComment) { - Image(systemName: "message") - .font(.system(size: 16)) - .foregroundColor(AppColors.textSecondary) - .frame(width: 36, height: 36) - .background(AppColors.secondaryBackground) - .cornerRadius(AppStyles.cornerRadiusSmall) - } - } - } - - Spacer() - } - .padding(AppStyles.paddingMedium) - .cardStyle() - } -} diff --git a/frontend/MusIQ/Views/HomeFeedView.swift b/frontend/MusIQ/Views/HomeFeedView.swift deleted file mode 100644 index f0291c4..0000000 --- a/frontend/MusIQ/Views/HomeFeedView.swift +++ /dev/null @@ -1,106 +0,0 @@ -import SwiftUI - -struct HomeFeedView: View { - @StateObject private var viewModel = HomeFeedViewModel() - @StateObject private var ratingViewModel = RatingViewModel() - @ObservedObject var appState = AppState.shared - @State private var showProfile = false - - var body: some View { - ZStack { - AppColors.background - .ignoresSafeArea() - - VStack(spacing: 0) { - - VStack(spacing: 16) { - HStack { - Text("MusIQ") - .font(.system(size: 32, weight: .bold)) - .foregroundColor(AppColors.textPrimary) - .shadow(color: AppColors.primary.opacity(0.1), radius: 20) - - Spacer() - - Button(action: { - showProfile = true - }) { - Image(systemName: "person.circle.fill") - .font(.system(size: 24)) - .foregroundColor(AppColors.textSecondary) - .frame(width: 40, height: 40) - } - } - } - .padding(.horizontal, AppStyles.paddingMedium) - .padding(.top, AppStyles.paddingLarge) - .padding(.bottom, AppStyles.paddingMedium) - - if viewModel.isLoading { - Spacer() - ProgressView() - .tint(AppColors.primary) - Spacer() - } else if viewModel.feedItems.isEmpty { - Spacer() - VStack(spacing: 16) { - Image(systemName: "music.note.list") - .font(.system(size: 48)) - .foregroundColor(AppColors.textSecondary) - - Text("No music found") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - } - Spacer() - } else { - ScrollView { - LazyVStack(spacing: 16) { - ForEach(viewModel.feedItems) { item in - FeedCardView( - item: item, - onRate: { - viewModel.selectItemForRating(item) - }, - onFavorite: {}, - onComment: {} - ) - .padding(.horizontal, AppStyles.paddingMedium) - } - } - .padding(.top, 8) - .padding(.bottom, 20) - } - } - } - } - .sheet(isPresented: $viewModel.showRatingModal) { - if let item = viewModel.selectedItem { - RatingModalView( - viewModel: ratingViewModel, - item: item, - onClose: { - viewModel.showRatingModal = false - ratingViewModel.reset() - }, - onSubmit: { rating, tags in - - Task { - await viewModel.refreshFeed() - } - } - ) - } - } - .sheet(isPresented: $showProfile) { - ProfileView(appState: appState) - } - .task { - await viewModel.loadFeed() - } - } -} - -#Preview { - HomeFeedView() -} diff --git a/frontend/MusIQ/Views/MainAppView.swift b/frontend/MusIQ/Views/MainAppView.swift deleted file mode 100644 index f516684..0000000 --- a/frontend/MusIQ/Views/MainAppView.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SwiftUI - -struct MainAppView: View { - @ObservedObject var appState: AppState - - var body: some View { - ZStack { - AppColors.background - .ignoresSafeArea() - - HomeFeedView() - } - } -} - -#Preview { - MainAppView(appState: AppState()) -} diff --git a/frontend/MusIQ/Views/OnboardingView.swift b/frontend/MusIQ/Views/OnboardingView.swift deleted file mode 100644 index c68d4ff..0000000 --- a/frontend/MusIQ/Views/OnboardingView.swift +++ /dev/null @@ -1,185 +0,0 @@ -import SwiftUI - -struct OnboardingSlide { - let icon: String - let title: String - let description: String - let color: Color -} - -struct OnboardingView: View { - @State private var currentSlide = 0 - @State private var slideOffset: CGFloat = 0 - - let slides: [OnboardingSlide] = [ - OnboardingSlide( - icon: "music.note", - title: "Rate Your Music", - description: "Share your honest opinions on albums, songs, and artists", - color: AppColors.primary - ), - OnboardingSlide( - icon: "sparkles", - title: "Discover New Sounds", - description: "Explore trending music and personalized recommendations", - color: AppColors.secondary - ), - OnboardingSlide( - icon: "person.2.fill", - title: "Influence the Charts", - description: "Your ratings shape the global music rankings", - color: AppColors.accent - ), - OnboardingSlide( - icon: "trophy.fill", - title: "Build Your Profile", - description: "Create your unique taste DNA and compare with friends", - color: AppColors.secondary - ) - ] - - let onComplete: () -> Void - - var body: some View { - ZStack { - AppColors.background - .ignoresSafeArea() - - VStack(spacing: 0) { - - HStack { - Spacer() - Button("Skip") { - onComplete() - } - .foregroundColor(AppColors.textSecondary) - .font(.system(size: 14)) - .padding(.trailing, AppStyles.paddingMedium) - .padding(.top, AppStyles.paddingMedium) - } - - Spacer() - - TabView(selection: $currentSlide) { - ForEach(0.. Void - let onSubmit: (Int, [String]) -> Void - - private func iconName(for type: MusicItemType) -> String { - switch type { - case .album: - return "opticaldisc.fill" - case .song: - return "music.note" - case .artist: - return "person.fill" - } - } - - private func iconColor(for type: MusicItemType) -> Color { - switch type { - case .album: - return AppColors.primary - case .song: - return AppColors.secondary - case .artist: - return AppColors.accent - } - } - - var body: some View { - ZStack { - Color.black.opacity(0.8) - .ignoresSafeArea() - .onTapGesture { - onClose() - } - - VStack(spacing: 0) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Rate this music") - .font(.system(size: 20, weight: .bold)) - .foregroundColor(AppColors.textPrimary) - - Text("Share your honest opinion") - .font(.system(size: 14)) - .foregroundColor(AppColors.textSecondary) - } - - Spacer() - - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 18)) - .foregroundColor(AppColors.textSecondary) - .frame(width: 32, height: 32) - } - } - .padding(AppStyles.paddingLarge) - - if let item = item { - HStack(spacing: 16) { - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .fill(AppColors.secondaryBackground) - .frame(width: 80, height: 80) - .overlay( - Image(systemName: iconName(for: item.type)) - .font(.system(size: 32)) - .foregroundColor(iconColor(for: item.type)) - ) - - VStack(alignment: .leading, spacing: 8) { - Text(item.title) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(AppColors.textPrimary) - .lineLimit(1) - - Text(item.artist) - .font(.system(size: 14)) - .foregroundColor(AppColors.textSecondary) - .lineLimit(1) - - HStack(spacing: 4) { - Image(systemName: "star.fill") - .font(.system(size: 12)) - .foregroundColor(AppColors.secondary) - - Text(String(format: "%.1f", item.rating)) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(AppColors.textPrimary) - - Text("current") - .font(.system(size: 12)) - .foregroundColor(AppColors.textSecondary) - } - } - - Spacer() - } - .padding(AppStyles.paddingMedium) - .background(AppColors.background) - .cornerRadius(AppStyles.cornerRadiusMedium) - .padding(.horizontal, AppStyles.paddingLarge) - - VStack(spacing: 16) { - Text("Your rating") - .font(.system(size: 14)) - .foregroundColor(AppColors.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(spacing: 8) { - ForEach(1...10, id: \.self) { star in - Button(action: { - viewModel.setRating(star) - }) { - Image(systemName: "star.fill") - .font(.system(size: 28)) - .foregroundColor( - star <= (viewModel.hoverRating > 0 ? viewModel.hoverRating : viewModel.rating) ? - AppColors.secondary : - AppColors.secondaryBackground - ) - } - .onHover { hovering in - if hovering { - viewModel.setHoverRating(star) - } else { - viewModel.setHoverRating(0) - } - } - } - } - - if viewModel.rating > 0 { - Text("\(viewModel.rating)/10") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(AppColors.primary) - .transition(.opacity) - } - } - .padding(.horizontal, AppStyles.paddingLarge) - .padding(.top, AppStyles.paddingLarge) - - VStack(alignment: .leading, spacing: 12) { - Text("Add tags (optional)") - .font(.system(size: 14)) - .foregroundColor(AppColors.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - - FlowLayout(spacing: 8) { - ForEach(viewModel.availableTags, id: \.self) { tag in - Button(action: { - viewModel.toggleTag(tag) - }) { - Text(tag) - .font(.system(size: 12)) - .foregroundColor( - viewModel.selectedTags.contains(tag) ? - AppColors.textPrimary : - AppColors.textSecondary - ) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - viewModel.selectedTags.contains(tag) ? - AppColors.primary : - AppColors.secondaryBackground - ) - .cornerRadius(20) - } - } - } - } - .padding(.horizontal, AppStyles.paddingLarge) - .padding(.top, AppStyles.paddingLarge) - - Button(action: { - Task { - if await viewModel.submitRating(for: item.id) { - onSubmit(viewModel.rating, Array(viewModel.selectedTags)) - onClose() - } - } - }) { - Text("Submit Rating") - .font(.system(size: 16, weight: .semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, AppStyles.paddingMedium) - } - .gradientButton(isEnabled: viewModel.rating > 0) - .padding(.horizontal, AppStyles.paddingLarge) - .padding(.top, AppStyles.paddingLarge) - - if viewModel.rating > 0 { - Text("Your rating will update the global score") - .font(.system(size: 12)) - .foregroundColor(AppColors.textSecondary) - .padding(.top, 8) - .transition(.opacity) - } - } - } - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusLarge) - .padding(.horizontal, AppStyles.paddingLarge) - .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 10) - } - } -} - -struct FlowLayout: Layout { - var spacing: CGFloat = 8 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let result = FlowResult( - in: proposal.width ?? .infinity, - subviews: subviews, - spacing: spacing - ) - return result.size - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let result = FlowResult( - in: bounds.width, - subviews: subviews, - spacing: spacing - ) - for (index, subview) in subviews.enumerated() { - subview.place(at: CGPoint(x: bounds.minX + result.frames[index].minX, y: bounds.minY + result.frames[index].minY), proposal: .unspecified) - } - } - - struct FlowResult { - var size: CGSize = .zero - var frames: [CGRect] = [] - - init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) { - var currentX: CGFloat = 0 - var currentY: CGFloat = 0 - var lineHeight: CGFloat = 0 - - for subview in subviews { - let size = subview.sizeThatFits(.unspecified) - - if currentX + size.width > maxWidth && currentX > 0 { - currentX = 0 - currentY += lineHeight + spacing - lineHeight = 0 - } - - frames.append(CGRect(x: currentX, y: currentY, width: size.width, height: size.height)) - lineHeight = max(lineHeight, size.height) - currentX += size.width + spacing - } - - self.size = CGSize(width: maxWidth, height: currentY + lineHeight) - } - } -} diff --git a/frontend/MusIQ/Views/SplashScreenView.swift b/frontend/MusIQ/Views/SplashScreenView.swift deleted file mode 100644 index 1d9bb5f..0000000 --- a/frontend/MusIQ/Views/SplashScreenView.swift +++ /dev/null @@ -1,127 +0,0 @@ -import SwiftUI - -struct SplashScreenView: View { - @State private var showContent = false - @State private var logoScale: CGFloat = 0 - @State private var logoRotation: Double = -180 - @State private var glowScale: CGFloat = 1 - @State private var glowOpacity: Double = 0.5 - @State private var sparkle1Rotation: Double = 0 - @State private var sparkle2Rotation: Double = 0 - @State private var loadingDots: [Bool] = [false, false, false] - - let onComplete: () -> Void - - var body: some View { - ZStack { - AppGradients.background - .ignoresSafeArea() - - VStack(spacing: 32) { - ZStack { - Circle() - .fill(AppColors.primary.opacity(glowOpacity * 0.3)) - .frame(width: 140, height: 140) - .blur(radius: 20) - .scaleEffect(glowScale) - - ZStack { - Circle() - .fill(AppColors.primary) - .frame(width: 96, height: 96) - - Image(systemName: "music.note") - .font(.system(size: 48)) - .foregroundColor(.white) - } - .scaleEffect(logoScale) - .rotationEffect(.degrees(logoRotation)) - - Image(systemName: "sparkles") - .font(.system(size: 24)) - .foregroundColor(AppColors.primary) - .offset(x: -48, y: -48) - .rotationEffect(.degrees(sparkle1Rotation)) - .scaleEffect(glowScale) - - Image(systemName: "sparkles") - .font(.system(size: 20)) - .foregroundColor(AppColors.secondary) - .offset(x: 48, y: 48) - .rotationEffect(.degrees(sparkle2Rotation)) - .scaleEffect(glowScale * 1.2) - } - .padding(.top, 100) - - VStack(spacing: 8) { - Text("MusIQ") - .font(.system(size: 48, weight: .bold)) - .foregroundColor(AppColors.textPrimary) - .shadow(color: AppColors.primary.opacity(0.2), radius: 20) - .opacity(showContent ? 1 : 0) - - Text("Rate. Discover. Influence.") - .font(.system(size: 16)) - .foregroundColor(AppColors.textSecondary) - .opacity(showContent ? 1 : 0) - } - .padding(.top, 32) - - HStack(spacing: 8) { - ForEach(0..<3) { index in - Circle() - .fill( - loadingDots[index] ? - AppColors.primary : - AppColors.secondary.opacity(0.5) - ) - .frame(width: 8, height: 8) - } - } - .padding(.top, 48) - .opacity(showContent ? 1 : 0) - } - } - .onAppear { - withAnimation(.spring(response: 0.8, dampingFraction: 0.6)) { - logoScale = 1 - logoRotation = 0 - } - - withAnimation(.easeInOut(duration: 0.3).delay(0.3)) { - showContent = true - } - - withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) { - glowScale = 1.2 - glowOpacity = 0.8 - } - - withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) { - sparkle1Rotation = 360 - } - - withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) { - sparkle2Rotation = -360 - } - - for i in 0..<3 { - withAnimation( - .easeInOut(duration: 1) - .repeatForever(autoreverses: true) - .delay(Double(i) * 0.2) - ) { - loadingDots[i] = true - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - onComplete() - } - } - } -} - -#Preview { - SplashScreenView(onComplete: {}) -} diff --git a/frontend/MusIQTests/MusicAppTests.swift b/frontend/MusIQTests/MusicAppTests.swift deleted file mode 100644 index 1622811..0000000 --- a/frontend/MusIQTests/MusicAppTests.swift +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - -import Testing -@testable import MusicApp - -struct MusicAppTests { - - @Test func example() async throws { - - } - -} diff --git a/frontend/MusIQUITests/MusicAppUITests.swift b/frontend/MusIQUITests/MusicAppUITests.swift deleted file mode 100644 index 2a26e01..0000000 --- a/frontend/MusIQUITests/MusicAppUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - -import XCTest - -final class MusicAppUITests: XCTestCase { - - override func setUpWithError() throws { - - - - continueAfterFailure = false - - - } - - override func tearDownWithError() throws { - - } - - @MainActor - func testExample() throws { - - let app = XCUIApplication() - app.launch() - - - } - - @MainActor - func testLaunchPerformance() throws { - - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } -} diff --git a/frontend/MusIQUITests/MusicAppUITestsLaunchTests.swift b/frontend/MusIQUITests/MusicAppUITestsLaunchTests.swift deleted file mode 100644 index 599598e..0000000 --- a/frontend/MusIQUITests/MusicAppUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - -import XCTest - -final class MusicAppUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - - - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/frontend/MusicApp/Info.plist b/frontend/MusicApp/Info.plist index a964962..12286f0 100644 --- a/frontend/MusicApp/Info.plist +++ b/frontend/MusicApp/Info.plist @@ -68,7 +68,13 @@ NSIncludesSubdomains - + + 192.168.1.244 + + NSExceptionAllowsInsecureHTTPLoads + + + 192.168.86.133 NSExceptionAllowsInsecureHTTPLoads diff --git a/frontend/MusicApp/Models/Post.swift b/frontend/MusicApp/Models/Post.swift new file mode 100644 index 0000000..274ed08 --- /dev/null +++ b/frontend/MusicApp/Models/Post.swift @@ -0,0 +1,88 @@ +import Foundation + +struct Post: Identifiable, Codable { + let id: String + let username: String + let text: String? + let rating: Int + let musicItem: PostMusicItem + let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case username + case text + case rating + case musicItem + case createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + username = try container.decode(String.self, forKey: .username) + text = try container.decodeIfPresent(String.self, forKey: .text) + rating = try container.decode(Int.self, forKey: .rating) + musicItem = try container.decode(PostMusicItem.self, forKey: .musicItem) + + let formatter = ISO8601DateFormatter() + let createdAtString = try container.decode(String.self, forKey: .createdAt) + createdAt = formatter.date(from: createdAtString) ?? Date() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(username, forKey: .username) + try container.encodeIfPresent(text, forKey: .text) + try container.encode(rating, forKey: .rating) + try container.encode(musicItem, forKey: .musicItem) + + let formatter = ISO8601DateFormatter() + try container.encode(formatter.string(from: createdAt), forKey: .createdAt) + } +} + +struct PostMusicItem: Codable { + let id: String + let type: MusicItemType + let title: String + let artist: String? + let imageUrl: String? + let spotifyId: String? + let appleMusicId: String? + let metadata: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case id + case type + case title + case artist + case imageUrl + case spotifyId + case appleMusicId + case metadata + } +} + +struct CreatePostRequest: Codable { + let musicItemId: String + let rating: Int + let text: String? +} + +struct CreatePostWithMusicItemRequest: Codable { + let name: String + let category: String + let rating: Int + let text: String? +} + + +struct Pagination: Codable { + let page: Int + let limit: Int + let total: Int + let hasMore: Bool + let nextPage: Int? +} diff --git a/frontend/MusicApp/Services/APIService.swift b/frontend/MusicApp/Services/APIService.swift index a9fc912..cfd9e48 100644 --- a/frontend/MusicApp/Services/APIService.swift +++ b/frontend/MusicApp/Services/APIService.swift @@ -7,7 +7,7 @@ class APIService { private let session: URLSession init(baseURL: String? = nil) { - self.baseURL = baseURL ?? ProcessInfo.processInfo.environment["API_BASE_URL"] ?? "http://192.168.86.133:3000/api" + self.baseURL = baseURL ?? ProcessInfo.processInfo.environment["API_BASE_URL"] ?? "http://localhost:3000/api" let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 30 diff --git a/frontend/MusicApp/Services/PostService.swift b/frontend/MusicApp/Services/PostService.swift new file mode 100644 index 0000000..c32f9e9 --- /dev/null +++ b/frontend/MusicApp/Services/PostService.swift @@ -0,0 +1,76 @@ +import Foundation + +class PostService { + private let apiService = APIService.shared + + func createPost(musicItemId: String, rating: Int, text: String?) async throws -> Post { + let request = CreatePostRequest( + musicItemId: musicItemId, + rating: rating, + text: text + ) + + let response: APIResponse = try await apiService.request( + endpoint: "/posts", + method: .post, + body: request, + requiresAuth: true + ) + + guard response.success, let data = response.data else { + throw NetworkError.unknown(NSError(domain: "PostService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create post"])) + } + + return data.post + } + + func createPostWithMusicItem(name: String, category: MusicItemType, rating: Int, text: String?) async throws -> Post { + let request = CreatePostWithMusicItemRequest( + name: name, + category: category.rawValue, + rating: rating, + text: text + ) + + let response: APIResponse = try await apiService.request( + endpoint: "/posts/create", + method: .post, + body: request, + requiresAuth: true + ) + + guard response.success, let data = response.data else { + throw NetworkError.unknown(NSError(domain: "PostService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create post"])) + } + + return data.post + } + + func getFeed(page: Int = 1, limit: Int = 20) async throws -> (items: [Post], hasMore: Bool, nextPage: Int?) { + let endpoint = "/posts/feed?page=\(page)&limit=\(limit)" + let response: APIResponse = try await apiService.request( + endpoint: endpoint, + method: .get, + requiresAuth: true + ) + + guard response.success, let data = response.data else { + throw NetworkError.unknown(NSError(domain: "PostService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to load feed"])) + } + + return ( + items: data.data, + hasMore: data.pagination?.hasMore ?? false, + nextPage: data.pagination?.nextPage + ) + } +} + +struct PostResponse: Codable { + let post: Post +} + +struct PostFeedResponse: Codable { + let data: [Post] + let pagination: Pagination? +} diff --git a/frontend/MusicApp/ViewModels/CreatePostViewModel.swift b/frontend/MusicApp/ViewModels/CreatePostViewModel.swift new file mode 100644 index 0000000..a0f05b8 --- /dev/null +++ b/frontend/MusicApp/ViewModels/CreatePostViewModel.swift @@ -0,0 +1,72 @@ +import Foundation +import SwiftUI +import Combine + +@MainActor +class CreatePostViewModel: ObservableObject { + @Published var musicItemName: String = "" + @Published var category: MusicItemType = .song + @Published var rating: Int = 0 + @Published var hoverRating: Int = 0 + @Published var description: String = "" + @Published var isSubmitting: Bool = false + @Published var errorMessage: String? + + private let postService: PostService + + init(postService: PostService = PostService()) { + self.postService = postService + } + + var canSubmit: Bool { + !musicItemName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && rating > 0 + } + + func setRating(_ value: Int) { + rating = value + } + + func setHoverRating(_ value: Int) { + hoverRating = value + } + + func submitPost() async -> Bool { + guard canSubmit else { + errorMessage = "Please enter a music item name and select a rating" + return false + } + + isSubmitting = true + errorMessage = nil + + do { + let trimmedName = musicItemName.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + let finalDescription = trimmedDescription.isEmpty ? nil : trimmedDescription + + _ = try await postService.createPostWithMusicItem( + name: trimmedName, + category: category, + rating: rating, + text: finalDescription + ) + + reset() + isSubmitting = false + return true + } catch { + errorMessage = error.localizedDescription + isSubmitting = false + return false + } + } + + func reset() { + musicItemName = "" + category = .song + rating = 0 + hoverRating = 0 + description = "" + errorMessage = nil + } +} diff --git a/frontend/MusicApp/ViewModels/HomeFeedViewModel.swift b/frontend/MusicApp/ViewModels/HomeFeedViewModel.swift index f4e7494..935f61d 100644 --- a/frontend/MusicApp/ViewModels/HomeFeedViewModel.swift +++ b/frontend/MusicApp/ViewModels/HomeFeedViewModel.swift @@ -4,19 +4,26 @@ import SwiftUI @MainActor class HomeFeedViewModel: ObservableObject { - @Published var feedItems: [MusicItem] = [] + @Published var feedItems: [Post] = [] @Published var isLoading: Bool = false @Published var isLoadingMore: Bool = false @Published var errorMessage: String? @Published var selectedItem: MusicItem? @Published var showRatingModal: Bool = false + @Published var showSearchView: Bool = false + @Published var searchQuery: String = "" + @Published var searchResults: [MusicItem] = [] + @Published var isSearching: Bool = false + private let postService: PostService private let musicService: MusicService private var currentPage: Int = 1 private var hasMore: Bool = true private var isLoadingPage: Bool = false + private var searchTask: Task? - init(musicService: MusicService = MusicService()) { + init(postService: PostService = PostService(), musicService: MusicService = MusicService()) { + self.postService = postService self.musicService = musicService } @@ -30,7 +37,7 @@ class HomeFeedViewModel: ObservableObject { hasMore = true do { - let result = try await musicService.getFeed(filter: "forYou", page: currentPage) + let result = try await postService.getFeed(page: currentPage) feedItems = result.items hasMore = result.hasMore currentPage = result.nextPage ?? currentPage @@ -43,7 +50,7 @@ class HomeFeedViewModel: ObservableObject { isLoadingPage = false } - func loadMoreIfNeeded(currentItem: MusicItem?) async { + func loadMoreIfNeeded(currentItem: Post?) async { guard hasMore, !isLoadingPage, !isLoadingMore else { return } guard let currentItem = currentItem, @@ -56,7 +63,7 @@ class HomeFeedViewModel: ObservableObject { isLoadingPage = true do { - let result = try await musicService.getFeed(filter: "forYou", page: currentPage) + let result = try await postService.getFeed(page: currentPage) feedItems.append(contentsOf: result.items) hasMore = result.hasMore currentPage = result.nextPage ?? currentPage @@ -76,4 +83,39 @@ class HomeFeedViewModel: ObservableObject { func refreshFeed() async { await loadFeed() } + + func searchMusic(query: String) async { + guard query.count >= 2 else { + searchResults = [] + return + } + + isSearching = true + searchTask?.cancel() + + searchTask = Task { + do { + let results = try await musicService.search(query: query) + if !Task.isCancelled { + await MainActor.run { + self.searchResults = results + self.isSearching = false + } + } + } catch { + if !Task.isCancelled { + await MainActor.run { + self.searchResults = [] + self.isSearching = false + } + } + } + } + } + + func selectMusicItemForPost(_ item: MusicItem) { + selectedItem = item + showSearchView = false + showRatingModal = true + } } diff --git a/frontend/MusicApp/ViewModels/RatingViewModel.swift b/frontend/MusicApp/ViewModels/RatingViewModel.swift index cf272c5..de496b9 100644 --- a/frontend/MusicApp/ViewModels/RatingViewModel.swift +++ b/frontend/MusicApp/ViewModels/RatingViewModel.swift @@ -6,13 +6,14 @@ import SwiftUI class RatingViewModel: ObservableObject { @Published var rating: Int = 0 @Published var hoverRating: Int = 0 + @Published var postText: String = "" @Published var isSubmitting: Bool = false @Published var errorMessage: String? - private let ratingService: RatingService + private let postService: PostService - init(ratingService: RatingService = RatingService()) { - self.ratingService = ratingService + init(postService: PostService = PostService()) { + self.postService = postService } func setRating(_ value: Int) { @@ -33,16 +34,18 @@ class RatingViewModel: ObservableObject { errorMessage = nil do { - let request = CreateRatingRequest( + let text = postText.trimmingCharacters(in: .whitespacesAndNewlines) + let finalText = text.isEmpty ? nil : text + + _ = try await postService.createPost( musicItemId: musicItemId, rating: rating, - tags: nil + text: finalText ) - _ = try await ratingService.submitRating(request: request) - rating = 0 hoverRating = 0 + postText = "" isSubmitting = false return true @@ -56,6 +59,7 @@ class RatingViewModel: ObservableObject { func reset() { rating = 0 hoverRating = 0 + postText = "" errorMessage = nil } } diff --git a/frontend/MusicApp/Views/CreatePostModalView.swift b/frontend/MusicApp/Views/CreatePostModalView.swift new file mode 100644 index 0000000..6c21a46 --- /dev/null +++ b/frontend/MusicApp/Views/CreatePostModalView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct CreatePostModalView: View { + @ObservedObject var viewModel: CreatePostViewModel + let onClose: () -> Void + let onSubmit: () -> Void + + var body: some View { + NavigationView { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Music Item") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + + TextField("Enter artist, song, or album name", text: $viewModel.musicItemName) + .textFieldStyle(.plain) + .padding(AppStyles.paddingMedium) + .background(AppColors.secondaryBackground) + .cornerRadius(AppStyles.cornerRadiusSmall) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Category") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + + Picker("Category", selection: $viewModel.category) { + Text("Album").tag(MusicItemType.album) + Text("Song").tag(MusicItemType.song) + Text("Artist").tag(MusicItemType.artist) + } + .pickerStyle(.segmented) + } + + VStack(alignment: .leading, spacing: 12) { + Text("Rating") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + + HStack(spacing: 8) { + ForEach(1...10, id: \.self) { star in + Button(action: { + viewModel.setRating(star) + }) { + Image(systemName: "star.fill") + .font(.system(size: 28)) + .foregroundColor( + star <= (viewModel.hoverRating > 0 ? viewModel.hoverRating : viewModel.rating) ? + AppColors.secondary : + AppColors.secondaryBackground + ) + } + .onHover { hovering in + if hovering { + viewModel.setHoverRating(star) + } else { + viewModel.setHoverRating(0) + } + } + } + } + + if viewModel.rating > 0 { + Text("\(viewModel.rating)/10") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(AppColors.primary) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Description (optional)") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + + TextField("Share your thoughts...", text: $viewModel.description, axis: .vertical) + .textFieldStyle(.plain) + .padding(AppStyles.paddingMedium) + .background(AppColors.secondaryBackground) + .cornerRadius(AppStyles.cornerRadiusSmall) + .lineLimit(3...8) + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.system(size: 14)) + .foregroundColor(.red) + .padding(.horizontal, AppStyles.paddingMedium) + } + } + .padding(AppStyles.paddingLarge) + } + + Button(action: { + Task { + if await viewModel.submitPost() { + onSubmit() + onClose() + } + } + }) { + Text("Post") + .font(.system(size: 16, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, AppStyles.paddingMedium) + } + .gradientButton(isEnabled: viewModel.canSubmit && !viewModel.isSubmitting) + .padding(.horizontal, AppStyles.paddingLarge) + .padding(.bottom, AppStyles.paddingLarge) + } + .background(AppColors.background) + .navigationTitle("Create Post") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + onClose() + } + } + } + } + } +} diff --git a/frontend/MusicApp/Views/HomeFeedView.swift b/frontend/MusicApp/Views/HomeFeedView.swift index f8761a6..0e5798e 100644 --- a/frontend/MusicApp/Views/HomeFeedView.swift +++ b/frontend/MusicApp/Views/HomeFeedView.swift @@ -3,8 +3,10 @@ import SwiftUI struct HomeFeedView: View { @StateObject private var viewModel = HomeFeedViewModel() @StateObject private var ratingViewModel = RatingViewModel() + @StateObject private var createPostViewModel = CreatePostViewModel() @ObservedObject var appState = AppState.shared @State private var showProfile = false + @State private var showCreatePostModal = false var body: some View { ZStack { @@ -44,11 +46,11 @@ struct HomeFeedView: View { } else if viewModel.feedItems.isEmpty { Spacer() VStack(spacing: 16) { - Image(systemName: "music.note.list") + Image(systemName: "bubble.left.and.bubble.right") .font(.system(size: 48)) .foregroundColor(AppColors.textSecondary) - Text("No music found") + Text("No posts yet") .font(.system(size: 18, weight: .medium)) .foregroundColor(AppColors.textSecondary) } @@ -56,19 +58,14 @@ struct HomeFeedView: View { } else { ScrollView { LazyVStack(spacing: 16) { - ForEach(viewModel.feedItems) { item in - FeedCardView( - item: item, - onRate: { - viewModel.selectItemForRating(item) + ForEach(viewModel.feedItems) { post in + PostCardView(post: post) + .padding(.horizontal, AppStyles.paddingMedium) + .onAppear { + Task { + await viewModel.loadMoreIfNeeded(currentItem: post) + } } - ) - .padding(.horizontal, AppStyles.paddingMedium) - .onAppear { - Task { - await viewModel.loadMoreIfNeeded(currentItem: item) - } - } } if viewModel.isLoadingMore { @@ -82,6 +79,40 @@ struct HomeFeedView: View { } } } + + VStack { + Spacer() + HStack { + Spacer() + Button(action: { + showCreatePostModal = true + }) { + Image(systemName: "plus") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 56, height: 56) + .background(AppColors.primary) + .clipShape(Circle()) + .shadow(color: AppColors.primary.opacity(0.3), radius: 8, x: 0, y: 4) + } + .padding(.trailing, AppStyles.paddingLarge) + .padding(.bottom, AppStyles.paddingLarge) + } + } + } + .sheet(isPresented: $showCreatePostModal) { + CreatePostModalView( + viewModel: createPostViewModel, + onClose: { + showCreatePostModal = false + createPostViewModel.reset() + }, + onSubmit: { + Task { + await viewModel.refreshFeed() + } + } + ) } .sheet(isPresented: $viewModel.showRatingModal) { if let item = viewModel.selectedItem { @@ -92,7 +123,7 @@ struct HomeFeedView: View { viewModel.showRatingModal = false ratingViewModel.reset() }, - onSubmit: { rating in + onSubmit: { _ in Task { await viewModel.refreshFeed() } diff --git a/frontend/MusicApp/Views/MusicSearchView.swift b/frontend/MusicApp/Views/MusicSearchView.swift new file mode 100644 index 0000000..2be31b4 --- /dev/null +++ b/frontend/MusicApp/Views/MusicSearchView.swift @@ -0,0 +1,184 @@ +import SwiftUI + +struct MusicSearchView: View { + @ObservedObject var viewModel: HomeFeedViewModel + @FocusState private var isSearchFocused: Bool + + var body: some View { + NavigationView { + VStack(spacing: 0) { + HStack { + TextField("Search for music...", text: $viewModel.searchQuery) + .textFieldStyle(.plain) + .padding(AppStyles.paddingMedium) + .background(AppColors.secondaryBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .focused($isSearchFocused) + .onChange(of: viewModel.searchQuery) { _, newValue in + Task { + await viewModel.searchMusic(query: newValue) + } + } + + if !viewModel.searchQuery.isEmpty { + Button(action: { + viewModel.searchQuery = "" + viewModel.searchResults = [] + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(AppColors.textSecondary) + } + } + } + .padding(AppStyles.paddingMedium) + + if viewModel.isSearching { + Spacer() + ProgressView() + .tint(AppColors.primary) + Spacer() + } else if viewModel.searchResults.isEmpty && !viewModel.searchQuery.isEmpty { + Spacer() + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundColor(AppColors.textSecondary) + + Text("No results found") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + Text("Try searching for an artist, song, or album") + .font(.system(size: 14)) + .foregroundColor(AppColors.textSecondary) + } + Spacer() + } else if viewModel.searchQuery.isEmpty { + Spacer() + VStack(spacing: 16) { + Image(systemName: "music.note.list") + .font(.system(size: 48)) + .foregroundColor(AppColors.textSecondary) + + Text("Search for music") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(AppColors.textPrimary) + + Text("Find artists, songs, or albums to rate") + .font(.system(size: 14)) + .foregroundColor(AppColors.textSecondary) + } + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.searchResults) { item in + MusicSearchResultCard(item: item) { + viewModel.selectMusicItemForPost(item) + } + .padding(.horizontal, AppStyles.paddingMedium) + } + } + .padding(.top, 8) + .padding(.bottom, 20) + } + } + } + .background(AppColors.background) + .navigationTitle("Create Post") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + viewModel.showSearchView = false + viewModel.searchQuery = "" + viewModel.searchResults = [] + } + } + } + } + .onAppear { + isSearchFocused = true + } + } +} + +struct MusicSearchResultCard: View { + let item: MusicItem + let onSelect: () -> Void + + private var iconName: String { + switch item.type { + case .album: + return "opticaldisc.fill" + case .song: + return "music.note" + case .artist: + return "person.fill" + } + } + + private var iconColor: Color { + switch item.type { + case .album: + return AppColors.primary + case .song: + return AppColors.secondary + case .artist: + return AppColors.accent + } + } + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusSmall) + .fill(AppColors.secondaryBackground) + .frame(width: 56, height: 56) + .overlay( + Image(systemName: iconName) + .font(.system(size: 24)) + .foregroundColor(iconColor) + ) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: iconName) + .font(.system(size: 10)) + .foregroundColor(iconColor) + + Text(item.type.rawValue.capitalized) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(iconColor) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(iconColor.opacity(0.15)) + .cornerRadius(8) + + Text(item.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + .lineLimit(1) + + if let artist = item.artist { + Text(artist) + .font(.system(size: 14)) + .foregroundColor(AppColors.textSecondary) + .lineLimit(1) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(AppColors.textSecondary) + } + .padding(AppStyles.paddingMedium) + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + } + .buttonStyle(.plain) + } +} diff --git a/frontend/MusicApp/Views/PostCardView.swift b/frontend/MusicApp/Views/PostCardView.swift new file mode 100644 index 0000000..404e0a9 --- /dev/null +++ b/frontend/MusicApp/Views/PostCardView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct PostCardView: View { + let post: Post + + private var iconName: String { + switch post.musicItem.type { + case .album: + return "opticaldisc.fill" + case .song: + return "music.note" + case .artist: + return "person.fill" + } + } + + private var iconColor: Color { + switch post.musicItem.type { + case .album: + return AppColors.primary + case .song: + return AppColors.secondary + case .artist: + return AppColors.accent + } + } + + private var timeAgo: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: post.createdAt, relativeTo: Date()) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(post.username) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + + Text(timeAgo) + .font(.system(size: 13)) + .foregroundColor(AppColors.textSecondary) + + Spacer() + } + + if let text = post.text, !text.isEmpty { + Text(text) + .font(.system(size: 15)) + .foregroundColor(AppColors.textPrimary) + .lineSpacing(4) + } + + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusSmall) + .fill(AppColors.secondaryBackground) + .frame(width: 64, height: 64) + .overlay( + Image(systemName: iconName) + .font(.system(size: 28)) + .foregroundColor(iconColor) + ) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: iconName) + .font(.system(size: 10)) + .foregroundColor(iconColor) + + Text(post.musicItem.type.rawValue.capitalized) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(iconColor) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(iconColor.opacity(0.15)) + .cornerRadius(8) + + Text(post.musicItem.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + .lineLimit(1) + + if let artist = post.musicItem.artist { + Text(artist) + .font(.system(size: 12)) + .foregroundColor(AppColors.textSecondary) + .lineLimit(1) + } + } + + Spacer() + + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 14)) + .foregroundColor(AppColors.secondary) + + Text("\(post.rating)/10") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + } + } + .padding(AppStyles.paddingSmall) + .background(AppColors.secondaryBackground.opacity(0.5)) + .cornerRadius(AppStyles.cornerRadiusSmall) + } + .padding(AppStyles.paddingMedium) + .cardStyle() + } +} diff --git a/frontend/MusicApp/Views/RatingModalView.swift b/frontend/MusicApp/Views/RatingModalView.swift index 7aeb683..f40c5ad 100644 --- a/frontend/MusicApp/Views/RatingModalView.swift +++ b/frontend/MusicApp/Views/RatingModalView.swift @@ -160,6 +160,22 @@ struct RatingModalView: View { .padding(.horizontal, AppStyles.paddingLarge) .padding(.top, AppStyles.paddingLarge) + VStack(alignment: .leading, spacing: 8) { + Text("Add a comment (optional)") + .font(.system(size: 14)) + .foregroundColor(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + TextField("Share your thoughts...", text: $viewModel.postText, axis: .vertical) + .textFieldStyle(.plain) + .padding(AppStyles.paddingMedium) + .background(AppColors.secondaryBackground) + .cornerRadius(AppStyles.cornerRadiusSmall) + .lineLimit(3...6) + } + .padding(.horizontal, AppStyles.paddingLarge) + .padding(.top, AppStyles.paddingMedium) + Button(action: { Task { if await viewModel.submitRating(for: item.id) { @@ -168,22 +184,14 @@ struct RatingModalView: View { } } }) { - Text("Submit Rating") + Text("Post") .font(.system(size: 16, weight: .semibold)) .frame(maxWidth: .infinity) .padding(.vertical, AppStyles.paddingMedium) } - .gradientButton(isEnabled: viewModel.rating > 0) + .gradientButton(isEnabled: viewModel.rating > 0 && !viewModel.isSubmitting) .padding(.horizontal, AppStyles.paddingLarge) .padding(.top, AppStyles.paddingLarge) - - if viewModel.rating > 0 { - Text("Your rating will update the global score") - .font(.system(size: 12)) - .foregroundColor(AppColors.textSecondary) - .padding(.top, 8) - .transition(.opacity) - } } } .background(AppColors.cardBackground) diff --git a/frontend/MusicApp.xcodeproj/project.pbxproj b/frontend/a.xcodeproj/project.pbxproj similarity index 90% rename from frontend/MusicApp.xcodeproj/project.pbxproj rename to frontend/a.xcodeproj/project.pbxproj index 2dff835..fe65aff 100644 --- a/frontend/MusicApp.xcodeproj/project.pbxproj +++ b/frontend/a.xcodeproj/project.pbxproj @@ -29,9 +29,22 @@ C9C0A0A52F0C4962002E48B2 /* MusicAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MusicAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + C90B35522F1CA13E00123B83 /* Exceptions for "MusicApp" folder in "MusicApp" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C9C0A08D2F0C4960002E48B2 /* MusicApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ C9C0A0902F0C4960002E48B2 /* MusicApp */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C90B35522F1CA13E00123B83 /* Exceptions for "MusicApp" folder in "MusicApp" target */, + ); path = MusicApp; sourceTree = ""; }; @@ -106,6 +119,8 @@ buildRules = ( ); dependencies = ( + C92695862F1F363E005937E8 /* PBXTargetDependency */, + C92695842F1F3594005937E8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( C9C0A0902F0C4960002E48B2 /* MusicApp */, @@ -195,6 +210,9 @@ ); mainGroup = C9C0A0852F0C4960002E48B2; minimizedProjectReferenceProxies = 1; + packageReferences = ( + C908B2E22F1EE94D0045DFB6 /* XCRemoteSwiftPackageReference "supabase-swift" */, + ); preferredProjectObjectVersion = 77; productRefGroup = C9C0A08F2F0C4960002E48B2 /* Products */; projectDirPath = ""; @@ -256,6 +274,14 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + C92695842F1F3594005937E8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = C92695832F1F3594005937E8 /* Supabase */; + }; + C92695862F1F363E005937E8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = C92695852F1F363E005937E8 /* Auth */; + }; C9C0A09D2F0C4962002E48B2 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C9C0A08D2F0C4960002E48B2 /* MusicApp */; @@ -396,12 +422,14 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MusicApp/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_PREPROCESS = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -427,12 +455,14 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MusicApp/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_PREPROCESS = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -570,6 +600,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + C908B2E22F1EE94D0045DFB6 /* XCRemoteSwiftPackageReference "supabase-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/supabase-community/supabase-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C92695832F1F3594005937E8 /* Supabase */ = { + isa = XCSwiftPackageProductDependency; + package = C908B2E22F1EE94D0045DFB6 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Supabase; + }; + C92695852F1F363E005937E8 /* Auth */ = { + isa = XCSwiftPackageProductDependency; + package = C908B2E22F1EE94D0045DFB6 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Auth; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = C9C0A0862F0C4960002E48B2 /* Project object */; } diff --git a/frontend/MusicApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/a.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/MusicApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to frontend/a.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/frontend/a.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/frontend/a.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..ce72cf2 --- /dev/null +++ b/frontend/a.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "497b223336ae156cc4edd8c12c3d176b975452a34540e6bdf0a3cf91a248bc9c", + "pins" : [ + { + "identity" : "supabase-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/supabase-swift.git", + "state" : { + "revision" : "7a6f6554ce030d8733b3ba32d4255278450e5328", + "version" : "2.40.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "34e463e98ab8541c604af706c99bed7160f5ec70", + "version" : "1.8.1" + } + } + ], + "version" : 3 +} diff --git a/frontend/MusicApp.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme b/frontend/a.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme similarity index 100% rename from frontend/MusicApp.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme rename to frontend/a.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme diff --git a/frontend/MusicApp.xcodeproj/xcuserdata/aprameyakannan.xcuserdatad/xcschemes/xcschememanagement.plist b/frontend/a.xcodeproj/xcuserdata/aprameyakannan.xcuserdatad/xcschemes/xcschememanagement.plist similarity index 100% rename from frontend/MusicApp.xcodeproj/xcuserdata/aprameyakannan.xcuserdatad/xcschemes/xcschememanagement.plist rename to frontend/a.xcodeproj/xcuserdata/aprameyakannan.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/webapp/app/auth/callback/page.tsx b/webapp/app/auth/callback/page.tsx new file mode 100644 index 0000000..00e9eed --- /dev/null +++ b/webapp/app/auth/callback/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; + +function CallbackContent() { + const searchParams = useSearchParams(); + const type = searchParams.get('type'); + const error = searchParams.get('error'); + + let title = 'Action Successful'; + let message = 'Your request has been processed.'; + + if (error) { + title = 'Error'; + message = error; + } else if (type === 'signup') { + title = 'Email Verified!'; + message = 'Your email has been successfully verified.'; + } else if (type === 'email_change') { + title = 'Email Updated!'; + message = 'Your email address has been successfully updated.'; + } + + return ( +
+

+ {title} +

+

{message}

+

Please return to the MusIQ iOS app.

+
+ ); +} + +export default function CallbackPage() { + return ( +
+
+
+

MusIQ

+
+ Loading...
}> + + +
+ + ); +} diff --git a/webapp/app/auth/page.tsx b/webapp/app/auth/page.tsx index ee9cbc9..f83c4b6 100644 --- a/webapp/app/auth/page.tsx +++ b/webapp/app/auth/page.tsx @@ -2,114 +2,183 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { apiClient, SignupData, LoginData } from '@/lib/api'; +import { apiClient } from '@/lib/api'; -type AuthMode = 'signin' | 'signup'; - -export default function AuthPage() { - const [mode, setMode] = useState('signin'); +function ResetPasswordModal({ tokens, onClose }: { tokens: { access_token: string, refresh_token: string }, onClose: () => void }) { + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const router = useRouter(); + const [success, setSuccess] = useState(false); + const [passwordErrors, setPasswordErrors] = useState([]); useEffect(() => { - const checkAuth = async () => { - if (await apiClient.isAuthenticated()) { - router.push('/'); - return; - } - }; - checkAuth(); - }, [router]); + if (tokens.access_token && tokens.refresh_token) { + apiClient.setTokens({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token }); + } + }, [tokens]); - const [password, setPassword] = useState(''); - const [username, setUsername] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - - const [passwordErrors, setPasswordErrors] = useState([]); - const validatePassword = (pwd: string): string[] => { const errors: string[] = []; - if (pwd.length < 8 || pwd.length > 128) { - errors.push('Password must be between 8 and 128 characters'); - } - if (!/[a-z]/.test(pwd)) { - errors.push('Password must contain at least one lowercase letter'); - } - if (!/[A-Z]/.test(pwd)) { - errors.push('Password must contain at least one uppercase letter'); - } - if (!/\d/.test(pwd)) { - errors.push('Password must contain at least one number'); - } - if (!/[@$!%*?&]/.test(pwd)) { - errors.push('Password must contain at least one special character (@$!%*?&)'); - } + if (pwd.length < 8 || pwd.length > 128) errors.push('8-128 characters'); + if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter'); + if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter'); + if (!/\d/.test(pwd)) errors.push('One number'); + if (!/[@$!%*?&]/.test(pwd)) errors.push('One special char (@$!%*?&)'); return errors; }; - const handleAuth = async (e: React.FormEvent) => { + const handleReset = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - setPasswordErrors([]); - setLoading(true); + const pwdErrors = validatePassword(password); + if (pwdErrors.length > 0) { + setPasswordErrors(pwdErrors); + setError('Please meet all password requirements.'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + setLoading(true); try { - if (mode === 'signup') { - if (password !== confirmPassword) { - setError('Passwords do not match'); - setLoading(false); - return; - } + const response = await apiClient.updatePassword(password); + if (response.success) { + setSuccess(true); + } else { + setError(response.error?.message || 'Failed to update password'); + } + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setLoading(false); + } + }; - const pwdErrors = validatePassword(password); - if (pwdErrors.length > 0) { - setPasswordErrors(pwdErrors); - setError('Please fix password requirements'); - setLoading(false); - return; - } + return ( +
+
+ {success ? ( +
+

Password Updated!

+

Your password has been changed successfully.

+

Please open the MusIQ iOS app to sign in.

+ +
+ ) : ( + <> +
+

Setup New Password

+ +
- if (username.length < 3 || username.length > 30) { - setError('Username must be between 3 and 30 characters'); - setLoading(false); - return; - } + {error && ( +
+ {error} +
+ )} - if (!/^[a-zA-Z0-9_]+$/.test(username)) { - setError('Username can only contain letters, numbers, and underscores'); - setLoading(false); - return; - } +
+
+ + { + setPassword(e.target.value); + setPasswordErrors(validatePassword(e.target.value)); + }} + required + className="w-full px-5 py-4 bg-secondaryBg border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition text-lg" + placeholder="Create a strong password" + /> + {passwordErrors.length > 0 && ( +
+ {passwordErrors.map((err, i) => • {err})} +
+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="w-full px-5 py-4 bg-secondaryBg border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition text-lg" + placeholder="Confirm your password" + /> +
- const signupData: SignupData = { - username, - password, - confirmPassword, - }; + +
+ + )} +
+
+ ); +} - const response = await apiClient.signup(signupData); +export default function AuthPage() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [email, setEmail] = useState(''); + const [resetSent, setResetSent] = useState(false); + const [recoveryTokens, setRecoveryTokens] = useState<{ access_token: string, refresh_token: string } | null>(null); + const router = useRouter(); - if (response.success && response.data) { - apiClient.setTokens(response.data); - router.push('/'); - } else { - setError(response.error?.message || 'Sign up failed'); + useEffect(() => { + const handleHashAndParams = () => { + // Handle Hash (Implicit Flow) + const hash = window.location.hash; + if (hash) { + const params = new URLSearchParams(hash.substring(1)); + if (params.get('type') === 'recovery') { + const access_token = params.get('access_token'); + const refresh_token = params.get('refresh_token'); + if (access_token && refresh_token) { + setRecoveryTokens({ access_token, refresh_token }); + return; + } } - } else { - const loginData: LoginData = { - username, - password, - }; + } - const response = await apiClient.login(loginData); + // Handle Query Params (PKCE/OTP Flow) redirect to dedicated page + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.get('type') === 'recovery' && searchParams.get('code')) { + router.push('/reset-password' + window.location.search); + } + }; + handleHashAndParams(); + }, [router]); - if (response.success && response.data) { - apiClient.setTokens(response.data); - router.push('/'); - } else { - setError(response.error?.message || 'Login failed'); - } + const handleForgotPassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const response = await apiClient.forgotPassword(email); + if (response.success) { + setResetSent(true); + } else { + setError(response.error?.message || 'Failed to send reset email'); } } catch (err) { setError('An unexpected error occurred'); @@ -120,140 +189,86 @@ export default function AuthPage() { return (
+ {recoveryTokens && ( + { + setRecoveryTokens(null); + window.location.hash = ''; + }} + /> + )} +
-

MusIQ

-

- {mode === 'signin' ? 'Welcome back' : 'Create your account'} -

+

MusIQ

+

Account Support

-
- {error && ( -
- {error} -
- )} - -
-
- - setUsername(e.target.value)} - required - minLength={3} - maxLength={30} - pattern="[a-zA-Z0-9_]+" - className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - placeholder={mode === 'signup' ? 'Choose a username (3-30 chars)' : 'Enter your username'} - /> - {mode === 'signup' && ( -

- Letters, numbers, and underscores only -

- )} +
+ {resetSent ? ( +
+
+ + + +
+

Check your email

+

+ We've sent a recovery link to {email}. +

+
+ ) : ( + <> +

Forgot Password?

+

Enter your email and we'll send you a link to reset your password.

-
- - { - setPassword(e.target.value); - if (mode === 'signup') { - setPasswordErrors(validatePassword(e.target.value)); - } - }} - required - minLength={8} - maxLength={128} - className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - placeholder={mode === 'signup' ? 'Create a strong password' : 'Enter your password'} - /> - {mode === 'signup' && passwordErrors.length > 0 && ( -
-

Password requirements:

-
    - {passwordErrors.map((err, idx) => ( -
  • {err}
  • - ))} -
+ {error && ( +
+ {error}
)} - {mode === 'signup' && password.length > 0 && passwordErrors.length === 0 && ( -

✓ Password meets all requirements

- )} -
- {mode === 'signup' && ( -
- - setConfirmPassword(e.target.value)} - required - minLength={8} - maxLength={128} - className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - placeholder="Confirm your password" - /> - {confirmPassword.length > 0 && password !== confirmPassword && ( -

Passwords do not match

- )} - {confirmPassword.length > 0 && password === confirmPassword && ( -

✓ Passwords match

- )} -
- )} - - - - -
- -
+
+
+ + setEmail(e.target.value)} + required + className="w-full px-5 py-4 bg-secondaryBg border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition text-lg" + placeholder="name@example.com" + /> +
+ +
+ + )}
-
-

- By continuing, you agree to MusIQ's{' '} - - Privacy Policy - -

+
+

Need help? Contact Support

+
+

+ To sign in or create an account, please use the MusIQ iOS App. +

+
diff --git a/webapp/app/favicon.ico b/webapp/app/favicon.ico deleted file mode 100644 index edd61e3..0000000 --- a/webapp/app/favicon.ico +++ /dev/null @@ -1,2 +0,0 @@ -# Placeholder favicon - Replace with actual app icon - diff --git a/webapp/app/page.tsx b/webapp/app/page.tsx index 09bdf63..0185a3c 100644 --- a/webapp/app/page.tsx +++ b/webapp/app/page.tsx @@ -1,105 +1,52 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { apiClient } from '@/lib/api'; export default function Home() { - const [isAuthenticated, setIsAuthenticated] = useState(null); - const [user, setUser] = useState(null); const router = useRouter(); useEffect(() => { - const checkAuth = async () => { - if (await apiClient.isAuthenticated()) { - try { - const response = await apiClient.getCurrentUser(); - if (response.success && response.data) { - setIsAuthenticated(true); - setUser(response.data); - } else { - setIsAuthenticated(false); - apiClient.clearTokens(); - router.push('/auth'); - } - } catch (error) { - setIsAuthenticated(false); - apiClient.clearTokens(); - router.push('/auth'); - } - } else { - setIsAuthenticated(false); - router.push('/auth'); - } - }; - - checkAuth(); - }, [router]); - - const handleLogout = async () => { - const refreshToken = apiClient.getRefreshToken(); - if (refreshToken) { - await apiClient.logout(refreshToken); + // If the user lands on the homepage with a Supabase auth hash (access_token, etc.), + // immediately redirect them to the auth page so it can be handled. + if (window.location.hash) { + router.replace('/auth' + window.location.hash); } - apiClient.clearTokens(); - setIsAuthenticated(false); - setUser(null); - }; - - if (isAuthenticated === null) { - return ( -
-
Loading...
-
- ); - } - - if (!isAuthenticated) { - return ( -
-
Redirecting...
-
- ); - } + }, [router]); return ( -
-
-
-
-

Welcome back!

-

- {user?.username || 'User'} -

-
- -
- -
-

Your Dashboard

-

- Start rating music and discover new sounds. Your ratings help shape global music rankings. +

+
+

MusIQ

+

+ The global music rating platform. +

+ +
+

+ Listen, Rate, Rank. +

+

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

+
+ + Account Support + +
-
- +
+ Support - - Privacy +
+ + Privacy Policy
diff --git a/webapp/app/reset-password/page.tsx b/webapp/app/reset-password/page.tsx new file mode 100644 index 0000000..d7572cc --- /dev/null +++ b/webapp/app/reset-password/page.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { apiClient } from '@/lib/api'; + +function ResetPasswordForm() { + const searchParams = useSearchParams(); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [passwordErrors, setPasswordErrors] = useState([]); + + const code = searchParams.get('code'); + + const validatePassword = (pwd: string): string[] => { + const errors: string[] = []; + if (pwd.length < 8 || pwd.length > 128) errors.push('8-128 characters'); + if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter'); + if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter'); + if (!/\d/.test(pwd)) errors.push('One number'); + if (!/[@$!%*?&]/.test(pwd)) errors.push('One special char (@$!%*?&)'); + return errors; + }; + + const handleReset = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const pwdErrors = validatePassword(password); + if (pwdErrors.length > 0) { + setPasswordErrors(pwdErrors); + setError('Please meet all password requirements.'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + if (!code) { + setError('Reset code is missing. Please request a new link.'); + return; + } + + setLoading(true); + try { + const response = await apiClient.resetPasswordWithCode(code, password); + if (response.success) { + setSuccess(true); + } else { + setError(response.error?.message || 'Failed to update password'); + } + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+

Password Updated!

+

Your password has been changed successfully.

+

Please open the MusIQ iOS app to sign in.

+
+ ); + } + + if (!code) { + return ( +
+

Link Invalid

+

This password reset link is invalid or expired.

+ Request a new link +
+ ); + } + + return ( +
+

New Password

+

Set a new password for your MusIQ account.

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + { + setPassword(e.target.value); + setPasswordErrors(validatePassword(e.target.value)); + }} + required + className="w-full px-5 py-4 bg-secondaryBg border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition text-lg" + placeholder="Min 8 characters" + /> + {passwordErrors.length > 0 && ( +
+ {passwordErrors.map((err, i) => • {err})} +
+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="w-full px-5 py-4 bg-secondaryBg border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition text-lg" + placeholder="Confirm your password" + /> +
+ + +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( +
+
+
+

MusIQ

+
+ Loading...
}> + + +
+
+ ); +} diff --git a/webapp/lib/api.ts b/webapp/lib/api.ts index 88b7a38..f1a9d87 100644 --- a/webapp/lib/api.ts +++ b/webapp/lib/api.ts @@ -1,4 +1,4 @@ -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://musiq-sc2d.onrender.com/api'; +const API_BASE_URL = 'http://localhost:3000/api'; //backend export interface AuthTokens { accessToken: string; @@ -36,7 +36,7 @@ class ApiClient { private getEncryptionKey(): string { if (typeof window === 'undefined') return ''; - + let key = sessionStorage.getItem('encryptionKey'); if (!key) { key = Array.from(crypto.getRandomValues(new Uint8Array(32))) @@ -49,17 +49,17 @@ class ApiClient { private encrypt(text: string): string { if (typeof window === 'undefined') return text; - + try { const key = this.getEncryptionKey(); const keyBytes = new TextEncoder().encode(key); const textBytes = new TextEncoder().encode(text); - - const encrypted = textBytes.map((byte, i) => + + const encrypted = textBytes.map((byte, i) => byte ^ keyBytes[i % keyBytes.length] ); - - return this.ENCRYPTION_PREFIX + btoa(String.fromCharCode(...encrypted)); + + return this.ENCRYPTION_PREFIX + btoa(String.fromCharCode.apply(null, Array.from(encrypted))); } catch (error) { console.error('Encryption error:', error); return text; @@ -68,21 +68,21 @@ class ApiClient { private decrypt(encryptedText: string): string { if (typeof window === 'undefined') return encryptedText; - + if (!encryptedText.startsWith(this.ENCRYPTION_PREFIX)) { return encryptedText; } - + try { const encrypted = encryptedText.slice(this.ENCRYPTION_PREFIX.length); const encryptedBytes = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0)); const key = this.getEncryptionKey(); const keyBytes = new TextEncoder().encode(key); - - const decrypted = encryptedBytes.map((byte, i) => + + const decrypted = encryptedBytes.map((byte, i) => byte ^ keyBytes[i % keyBytes.length] ); - + return new TextDecoder().decode(decrypted); } catch (error) { console.error('Decryption error:', error); @@ -97,9 +97,9 @@ class ApiClient { const url = `${this.baseUrl}${endpoint}`; const token = await this.getAccessToken(); - const headers: HeadersInit = { + const headers: Record = { 'Content-Type': 'application/json', - ...options.headers, + ...(options.headers as Record), }; if (token) { @@ -141,10 +141,9 @@ class ApiClient { } async signup(data: SignupData): Promise> { - const { confirmPassword, ...signupPayload } = data; return this.request('/auth/signup', { method: 'POST', - body: JSON.stringify(signupPayload), + body: JSON.stringify(data), }); } @@ -155,6 +154,27 @@ class ApiClient { }); } + async forgotPassword(email: string): Promise> { + return this.request('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }); + } + + async updatePassword(newPassword: string): Promise> { + return this.request('/auth/update-password', { + method: 'POST', + body: JSON.stringify({ newPassword }), + }); + } + + async resetPasswordWithCode(code: string, newPassword: string): Promise> { + return this.request('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ code, newPassword }), + }); + } + async refreshToken(refreshToken: string): Promise> { return this.request('/auth/refresh', { method: 'POST', diff --git a/webapp/package.json b/webapp/package.json index d826bee..bb69dd3 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -9,12 +9,13 @@ "lint": "next lint" }, "dependencies": { + "@supabase/supabase-js": "^2.90.1", "next": "^14.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { - "@types/node": "^20.0.0", + "@types/node": "^20.19.30", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "autoprefixer": "^10.4.16", @@ -23,4 +24,3 @@ "typescript": "^5.2.2" } } -