From 05c5aa22b7008062da290070b27129dc1a0ea894 Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Sat, 13 Sep 2025 23:13:04 -0400 Subject: [PATCH 01/20] Reset --- .gitignore | 2 ++ README.md | 67 ++++++++++++++++++++++++++++++++++++++++-- apps/web/next-env.d.ts | 6 ++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 apps/web/next-env.d.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83c97c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +apps/web/node_modules +apps/web/.env.local \ No newline at end of file diff --git a/README.md b/README.md index b5424ff..eb6e9cb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ -# Vybe -Social music networking app with shared playlists and daily vibes. +# 🎶 Vybe + +> *A social music networking web application that connects people through playlists and daily music sharing.* + +--- + +## 📌 Team Info + +**Team Name:** Vybe + +**Team Policies:** + +- **Absence Policy:** Absences are acceptable for illness, emergencies, or mental health days, provided they are not too frequent. Team members must always check in with the team leader whenever they are going to be absent. + +- **Ethic Policy:** + - *Unintentional issues:* minimal damage or deletion will result in a verbal warning, while major damage or deletion will be escalated to the team leader. + - *Intentional issues:* minimal damage or deletion will be escalated directly to the team leader, and major damage or deletion will be escalated to the professor. + - All infractions will be formally recorded. + +- **Language Policy:** English is the only language to be used, as the team does not share enough common experience in any other language to ensure clear communication. + +- **Code/Work Modification Guidelines:** Always create and work from branches, and make sure to pull frequently to stay up to date. Get permission before making any major changes, and always add comments—if it’s a minor change, include a short tagline explaining what was updated. Keep your section primary informed by posting updates in the Discord chat, and check in daily. Each section will maintain its own branch, with every member working from an individual sub-branch. + +- **Face Mask Policy:** Wearing a face mask is not required, but team members are free to wear one if they choose. + +- **Grade/Contribution Policy:** A student must have all peer reviews agree that they are contributing fairly to the project in order to meet expectations. If intentional damage occurs, it may result in a lower grade based on a consensus between the co-leaders and the respective primaries of the section involved, unless those individuals are themselves the ones in question. If the damage is unintentional, more than three recorded instances will also result in a lower score. Therefore, earning a grade of B- or higher requires that students avoid both intentional and repeated unintentional damage and contribute fairly to the project. + +- **Communication Standards:** All primary communication will take place through the Discord server. For section-specific matters, use your designated channel, as category and channel names are self-explanatory. Team members must keep Discord notifications on and ensure they are functioning at all times, though it is acceptable to mute lounge or unrelated technical discussion channels if desired. Response times are preferred to be within 3 hours, but end of day is the latest acceptable deadline. Transcripts will be recorded for each official meeting. Meeting schedules will be set weekly, with two meetings per week to ensure regular communication with the team leader and co-leader. Each member must attend at least one meeting per week. + +--- + +## 📖 Project Description + +**Vybe** is a social music networking web application that helps people connect through playlists and daily music sharing. Users can: +- 🔗 Generate **group playlists** that balance multiple people’s tastes using play histories, correlations, and genre analysis. +- 🎵 Share a **Song of the Day** to showcase their current vibe. +- 🖤 Interact with friends by seeing their daily tracks and playlists. +- 🌌 Enjoy a **modern dark-mode UI** enhanced by glowing chroma effects from album artwork. + +The goal of *Vybe* is to blend music discovery with social interaction, creating a space where friends can share their vibe and listen together. + +--- + +## 👥 Team Member Bios + +### Vas Anbukumar’s Bio: +I am a senior Computer Science student with a strong focus on backend development, data handling, and scalable systems. My experience includes working with Python, JavaScript, FastAPI, and cloud technologies to build pipelines, APIs, and intelligent applications. I enjoy bridging technical complexity with real-world usability, especially in projects that combine data, AI, and interactive user experiences. For Vybe, I’ll be anchoring backend development while supporting every other area of the project as needed. Outside of class, I enjoy exploring new tech tools and working on side projects that challenge me to grow. + +### Ezzat AbdelKhalek’s Bio: +I am a computer science undergraduate student with experience in Java, C#, Python and HTML/CSS. I enjoy learning new things and working on projects that let me apply what I’ve learned. For Vybe, I will focus on machine learning. Outside of school, I like spending time on reading books and learning new things. + +### Fahd Algahmi’s Bio: +I am a 3rd-year Computer Science student with knowledge in Java, Python, HTML, CSS, and JavaScript, as well as experience in both front-end and back-end development. For Vybe, I will be focusing on back-end development. I rarely have free time, but when I do, I enjoy spending it with my daughters. + +### Hayoung Jung’s Bio: +I am a senior Data Science and Analytics student interested in data management, documentation, and system testing. I like organizing project information and creating documentation that supports collaboration. For Vybe, I will be focusing on testing and documentation, front-end development. Outside of school, I enjoy cooking, reading fantasy novels, and exploring ways to stay organized. + +### S Ly’s Bio: +I am a post-grad student interested in artificial intelligence. I like making sure projects are reliable and well put together. For Vybe, I will be focusing on engine and machine learning. Outside of school, I enjoy playing and modding video games as well as watching sports, exercising, doing cardistry and playing cards. + +### Darius Robinson’s Bio: +I am a third year CS student with experience in both frontend and backend development. I've used languages like java, python, javascript and C. I like building projects that are interactive and useful. For Vybe, I will be focusing on the front end. In my free time, I enjoy video games and sports. + +### Charles Thorn’s Bio: +I am a senior computer science student with knowledge in Java, C#, JavaScript, node.js, html/css, sql. I enjoy being creative. For Vybe, I will be focusing on everything. Outside of school, I like sports/exercise, video editing, and watching tv/movies. diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 799073631296f282a54c2490be95ac28e808db76 Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Sat, 13 Sep 2025 23:25:09 -0400 Subject: [PATCH 02/20] Fixed small errors with README files. --- README.md | 24 ++++++++++++++++-------- apps/web/README.md | 2 ++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index eb6e9cb..ca35957 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ ## 📖 Project Description **Vybe** is a social music networking web application that helps people connect through playlists and daily music sharing. Users can: + - 🔗 Generate **group playlists** that balance multiple people’s tastes using play histories, correlations, and genre analysis. - 🎵 Share a **Song of the Day** to showcase their current vibe. - 🖤 Interact with friends by seeing their daily tracks and playlists. @@ -43,23 +44,30 @@ The goal of *Vybe* is to blend music discovery with social interaction, creating ## 👥 Team Member Bios -### Vas Anbukumar’s Bio: +### Vas Anbukumar’s Bio + I am a senior Computer Science student with a strong focus on backend development, data handling, and scalable systems. My experience includes working with Python, JavaScript, FastAPI, and cloud technologies to build pipelines, APIs, and intelligent applications. I enjoy bridging technical complexity with real-world usability, especially in projects that combine data, AI, and interactive user experiences. For Vybe, I’ll be anchoring backend development while supporting every other area of the project as needed. Outside of class, I enjoy exploring new tech tools and working on side projects that challenge me to grow. -### Ezzat AbdelKhalek’s Bio: +### Ezzat AbdelKhalek’s Bio + I am a computer science undergraduate student with experience in Java, C#, Python and HTML/CSS. I enjoy learning new things and working on projects that let me apply what I’ve learned. For Vybe, I will focus on machine learning. Outside of school, I like spending time on reading books and learning new things. -### Fahd Algahmi’s Bio: +### Fahd Algahmi’s Bio + I am a 3rd-year Computer Science student with knowledge in Java, Python, HTML, CSS, and JavaScript, as well as experience in both front-end and back-end development. For Vybe, I will be focusing on back-end development. I rarely have free time, but when I do, I enjoy spending it with my daughters. -### Hayoung Jung’s Bio: +### Hayoung Jung’s Bio + I am a senior Data Science and Analytics student interested in data management, documentation, and system testing. I like organizing project information and creating documentation that supports collaboration. For Vybe, I will be focusing on testing and documentation, front-end development. Outside of school, I enjoy cooking, reading fantasy novels, and exploring ways to stay organized. -### S Ly’s Bio: -I am a post-grad student interested in artificial intelligence. I like making sure projects are reliable and well put together. For Vybe, I will be focusing on engine and machine learning. Outside of school, I enjoy playing and modding video games as well as watching sports, exercising, doing cardistry and playing cards. +### S Ly’s Bio + +I am a senior CS student interested in artificial intelligence. I like making sure projects are reliable and well put together. For Vybe, I will be focusing on engine and machine learning. Outside of school, I enjoy playing and modding video games as well as watching sports, exercising, doing cardistry and playing cards. + +### Darius Robinson’s Bio -### Darius Robinson’s Bio: I am a third year CS student with experience in both frontend and backend development. I've used languages like java, python, javascript and C. I like building projects that are interactive and useful. For Vybe, I will be focusing on the front end. In my free time, I enjoy video games and sports. -### Charles Thorn’s Bio: +### Charles Thorn’s Bio + I am a senior computer science student with knowledge in Java, C#, JavaScript, node.js, html/css, sql. I enjoy being creative. For Vybe, I will be focusing on everything. Outside of school, I like sports/exercise, video editing, and watching tv/movies. diff --git a/apps/web/README.md b/apps/web/README.md index e215bc4..c5d3d57 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,3 +1,5 @@ +# Vybe Web Application + This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started From 3b1502133fda0f0d6c603c330d3f4d8b9763eace Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:39:56 -0400 Subject: [PATCH 03/20] Git commit issue --- .gitignore | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 83c97c7..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -apps/web/node_modules -apps/web/.env.local \ No newline at end of file From d2a9c5824952d33699e147a755c499de2bff2a15 Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:01:03 -0400 Subject: [PATCH 04/20] Adding PBI Issue Template --- .github/ISSUE_TEMPLATE/pbi.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/pbi.md diff --git a/.github/ISSUE_TEMPLATE/pbi.md b/.github/ISSUE_TEMPLATE/pbi.md new file mode 100644 index 0000000..191700c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pbi.md @@ -0,0 +1,31 @@ +# [PBI] + +## User Story + +As a , I want so that . + +- **Persona**: +- **Feature**: +- **Business Value**: + +--- + +## Reference(s) + +- Link(s) to design docs, wireframes, or related PBIs if any + +--- + +## Tasks + +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 + +--- + +## Acceptance Criteria + +- [ ] Clear condition 1 that must be true +- [ ] Clear condition 2 that must be true +- [ ] Any error handling or edge cases From 7da135fcfb9d614ae76ddad95f496b3242377ecb Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:04:48 -0400 Subject: [PATCH 05/20] Fixing issue template --- .github/ISSUE_TEMPLATE/pbi.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/pbi.md b/.github/ISSUE_TEMPLATE/pbi.md index 191700c..72b5959 100644 --- a/.github/ISSUE_TEMPLATE/pbi.md +++ b/.github/ISSUE_TEMPLATE/pbi.md @@ -1,3 +1,11 @@ +--- +name: "Product Backlog Item" +about: "Create a new Product Backlog Item in INVEST format" +title: "[PBI] " +labels: ["PBI"] +assignees: [] +--- + # [PBI] ## User Story From c15f1bd5e702c0b2ca2b8fd9bb055951e96f581d Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:06:58 -0400 Subject: [PATCH 06/20] Final Issue Template fix --- .github/ISSUE_TEMPLATE/pbi.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/pbi.md b/.github/ISSUE_TEMPLATE/pbi.md index 72b5959..b090d66 100644 --- a/.github/ISSUE_TEMPLATE/pbi.md +++ b/.github/ISSUE_TEMPLATE/pbi.md @@ -1,13 +1,11 @@ --- name: "Product Backlog Item" about: "Create a new Product Backlog Item in INVEST format" -title: "[PBI] " +title: " [PBI]" labels: ["PBI"] assignees: [] --- -# [PBI] - ## User Story As a , I want so that . From 6cfae59aa3ee09f25bc41f7a9e6f56bb791b053f Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Sat, 20 Sep 2025 16:23:43 -0400 Subject: [PATCH 07/20] chore: stop tracking files now ignored by .gitignore --- apps/web/next-env.d.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 apps/web/next-env.d.ts diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts deleted file mode 100644 index 830fb59..0000000 --- a/apps/web/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 5cf2219117b4afe347d5d3702268dc9cff66b0fe Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Sat, 20 Sep 2025 16:31:43 -0400 Subject: [PATCH 08/20] chore: honor .gitignore and stop tracking ignored files --- .filter-globs.txt | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .filter-globs.txt diff --git a/.filter-globs.txt b/.filter-globs.txt new file mode 100644 index 0000000..e86d928 --- /dev/null +++ b/.filter-globs.txt @@ -0,0 +1,39 @@ +# deps & Yarn internals (safe targets; keep the whitelisted dirs) +node_modules/** +.pnp +.pnp.* +.yarn/cache/** +.yarn/unplugged/** +.yarn/build-state.yml +.yarn/install-state.gz +.yarn/*.log + +# testing +coverage/** + +# next.js +.next/** +out/** + +# production +build/** + +# misc +.DS_Store +*.pem + +# debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel/** + +# typescript +*.tsbuildinfo +next-env.d.ts From cb58dc3f167b85fa907c7c4ff2f5fcc9cc76820a Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:02:10 -0400 Subject: [PATCH 09/20] chore: honor .gitignore (stop tracking build outputs) --- .gitignore | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..871057b --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# next / build +.next/ +out/ +build/ + +# deps +node_modules/ + +# logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env +.env* + +# misc +.DS_Store +.vercel/ +*.tsbuildinfo \ No newline at end of file From 627fe5b45c527a04c2e18517af68fe245a410378 Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:18:45 -0400 Subject: [PATCH 10/20] chore: ignore test files --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 871057b..450603c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,8 @@ yarn-error.log* # misc .DS_Store .vercel/ -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo +# test files +clean.txt +bad.txt +secret.txt From 0a30ef724dfab050efeb7c9b1051b891bfe6546f Mon Sep 17 00:00:00 2001 From: Vasanth Anbukumar <182255621+vas2000-emu@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:19:56 -0400 Subject: [PATCH 11/20] chore: ignore test files --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 450603c..90363ee 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ yarn-error.log* clean.txt bad.txt secret.txt + +# test files +clean.txt +bad.txt +secret.txt From bd6bf43c7a58864d6300aa9019a588167c11ac0a Mon Sep 17 00:00:00 2001 From: HuTaoEMU Date: Wed, 19 Nov 2025 14:04:34 -0500 Subject: [PATCH 12/20] added playlists onto the trending communities --- apps/web/.eslintignore | 9 + apps/web/.eslintrc.json | 11 + apps/web/.gitignore | 27 + apps/web/ADD_YOUTUBE_URL.sql | 11 + apps/web/SETUP_DATABASE.sql | 83 + apps/web/TESTING.md | 135 + .../web/app/(auth)/__tests__/sign-in.test.jsx | 241 + apps/web/app/(auth)/sign-in/page.jsx | 88 +- apps/web/app/api/friends/requests/route.js | 162 + apps/web/app/api/friends/route.js | 213 + apps/web/app/api/groups/route.js | 130 + apps/web/app/api/history/route.js | 41 + apps/web/app/api/import-playlist/route.js | 341 + apps/web/app/api/run-migration/route.js | 53 + apps/web/app/api/song-of-the-day/route.js | 182 + apps/web/app/api/spotify-search/route.js | 82 + apps/web/app/api/spotify/[...path]/route.js | 7 +- apps/web/app/api/user/account/delete/route.js | 162 + apps/web/app/api/user/export/route.js | 281 + apps/web/app/api/user/notifications/route.js | 324 + apps/web/app/api/user/privacy/route.js | 320 + .../web/app/api/user/profile/picture/route.js | 265 + apps/web/app/api/user/profile/route.js | 330 + apps/web/app/api/users/search/route.js | 124 + apps/web/app/api/youtube-search/route.js | 94 + apps/web/app/api/youtube/[...path]/route.js | 62 + apps/web/app/auth/callback/route.js | 154 +- apps/web/app/globals.css | 369 +- apps/web/app/groups/[id]/page.jsx | 1000 ++ apps/web/app/groups/[id]/page.jsx.bak | 912 ++ apps/web/app/groups/page.jsx | 392 + apps/web/app/layout.jsx | 28 +- apps/web/app/page.jsx | 35 +- apps/web/app/playlist/page.jsx | 21 + apps/web/app/profile/page.jsx | 485 + apps/web/app/settings/account/page.jsx | 346 + apps/web/app/settings/layout.jsx | 12 + apps/web/app/settings/notifications/page.jsx | 418 + apps/web/app/settings/page.jsx | 5 + apps/web/app/settings/privacy/page.jsx | 412 + apps/web/app/settings/profile/page.jsx | 401 + apps/web/components/AddFriendsModal.jsx | 183 + apps/web/components/Dashboard.jsx | 309 + apps/web/components/DeleteAccountModal.jsx | 374 + apps/web/components/FriendRequestsModal.jsx | 177 + apps/web/components/HomePage.jsx | 907 ++ apps/web/components/LibraryView.jsx | 528 +- apps/web/components/Navbar.jsx | 202 +- apps/web/components/NotificationToggle.jsx | 245 + apps/web/components/PrivacyToggle.jsx | 560 + apps/web/components/ProfilePictureUpload.jsx | 295 + apps/web/components/QueryProvider.jsx | 61 + apps/web/components/SaveStatusIndicator.jsx | 54 + .../web/components/SettingsConflictDialog.jsx | 232 + apps/web/components/SettingsNav.jsx | 242 + apps/web/components/SettingsPageWrapper.jsx | 266 + apps/web/components/SettingsSyncIndicator.jsx | 179 + apps/web/components/SongSearchModal.jsx | 281 + apps/web/components/ValidationError.jsx | 326 + .../__tests__/AddFriendsModal.test.jsx | 98 + .../__tests__/FriendRequestsModal.test.jsx | 249 + .../components/__tests__/HomePage.test.jsx | 644 + .../components/__tests__/LibraryView.test.jsx | 1205 ++ apps/web/components/__tests__/Navbar.test.jsx | 493 + .../__tests__/SongSearchModal.test.jsx | 243 + .../__tests__/shared-components.test.jsx | 429 + .../components/common/ImageWithFallback.jsx | 18 + apps/web/components/common/VybeLogo.jsx | 14 + .../components/shared/CommunitiesDialog.jsx | 92 + apps/web/components/shared/EmptyState.jsx | 22 + apps/web/components/shared/FormField.jsx | 93 + apps/web/components/shared/FullGroupCard.jsx | 111 + apps/web/components/shared/GlassCard.jsx | 31 + apps/web/components/shared/GroupCard.jsx | 57 + apps/web/components/shared/LoadingState.jsx | 18 + .../web/components/shared/ShareSongDialog.jsx | 141 + .../components/shared/SongDetailsDialog.jsx | 18 + apps/web/components/ui/alert.jsx | 19 + apps/web/components/ui/avatar.jsx | 24 + apps/web/components/ui/badge.jsx | 11 + apps/web/components/ui/button.jsx | 12 + apps/web/components/ui/card.jsx | 90 + apps/web/components/ui/dialog.jsx | 110 + apps/web/components/ui/input.jsx | 21 + apps/web/components/ui/label.jsx | 19 + apps/web/components/ui/select.jsx | 76 + apps/web/components/ui/sonner.jsx | 13 + apps/web/components/ui/switch.jsx | 26 + apps/web/components/ui/textarea.jsx | 18 + apps/web/components/ui/utils.js | 7 + apps/web/config/constants.js | 74 + apps/web/eslint.config.mjs | 4 + apps/web/hooks/useAutoSave.js | 272 + apps/web/hooks/useDialog.js | 17 + apps/web/hooks/useGroups.js | 128 + apps/web/hooks/useNotificationPreferences.js | 115 + apps/web/hooks/usePrivacySettings.js | 115 + apps/web/hooks/useProfileUpdate.js | 139 + apps/web/hooks/useSettingsMigration.js | 66 + apps/web/hooks/useSettingsSync.js | 574 + apps/web/hooks/useSettingsValidation.js | 248 + apps/web/hooks/useSocial.js | 76 + apps/web/lib/cache/settingsCache.js | 282 + apps/web/lib/getUserProvider.js | 54 + .../create_notification_preferences_table.sql | 171 + .../create_privacy_settings_table.sql | 161 + apps/web/lib/migrations/settingsMigrations.js | 420 + apps/web/lib/privacy/enforcer.js | 469 + apps/web/lib/schemas/notificationSchema.js | 268 + apps/web/lib/schemas/privacySchema.js | 299 + apps/web/lib/schemas/profileSchema.js | 192 + apps/web/lib/services/accountDeletion.js | 351 + apps/web/{app => }/lib/spotify.js | 6 +- apps/web/lib/utils/sanitization.js | 621 + .../web/lib/utils/settingsConflictResolver.js | 433 + apps/web/lib/validation/serverValidation.js | 385 + apps/web/lib/youtube.js | 106 + apps/web/middleware.js | 28 +- apps/web/package-lock.json | 12847 +++++++++++----- apps/web/package.json | 46 +- apps/web/playwright.config.ts | 75 + apps/web/postcss.config.mjs | 4 +- apps/web/sprint3.txt | 4 + apps/web/store/settingsStore.js | 457 + .../002_create_songs_of_the_day_table.sql | 62 + .../migrations/003_create_friendships.sql | 56 + .../migrations/004_update_users_table.sql | 94 + .../migrations/005_update_groups_table.sql | 142 + .../migrations/006_create_group_playlists.sql | 224 + .../migrations/007_add_last_used_provider.sql | 15 + .../008_ensure_updated_at_column.sql | 12 + apps/web/test/helpers.js | 57 + apps/web/test/setup.ts | 40 + apps/web/test/test-utils.jsx | 230 + apps/web/test/utils.test.js | 43 + apps/web/tests/e2e/app.spec.ts | 54 + apps/web/tests/e2e/sign-out.spec.ts | 168 + apps/web/utils/clipboard.js | 49 + apps/web/vitest.config.ts | 34 + 139 files changed, 35168 insertions(+), 4230 deletions(-) create mode 100644 apps/web/.eslintignore create mode 100644 apps/web/.eslintrc.json create mode 100644 apps/web/ADD_YOUTUBE_URL.sql create mode 100644 apps/web/SETUP_DATABASE.sql create mode 100644 apps/web/TESTING.md create mode 100644 apps/web/app/(auth)/__tests__/sign-in.test.jsx create mode 100644 apps/web/app/api/friends/requests/route.js create mode 100644 apps/web/app/api/friends/route.js create mode 100644 apps/web/app/api/groups/route.js create mode 100644 apps/web/app/api/history/route.js create mode 100644 apps/web/app/api/import-playlist/route.js create mode 100644 apps/web/app/api/run-migration/route.js create mode 100644 apps/web/app/api/song-of-the-day/route.js create mode 100644 apps/web/app/api/spotify-search/route.js create mode 100644 apps/web/app/api/user/account/delete/route.js create mode 100644 apps/web/app/api/user/export/route.js create mode 100644 apps/web/app/api/user/notifications/route.js create mode 100644 apps/web/app/api/user/privacy/route.js create mode 100644 apps/web/app/api/user/profile/picture/route.js create mode 100644 apps/web/app/api/user/profile/route.js create mode 100644 apps/web/app/api/users/search/route.js create mode 100644 apps/web/app/api/youtube-search/route.js create mode 100644 apps/web/app/api/youtube/[...path]/route.js create mode 100644 apps/web/app/groups/[id]/page.jsx create mode 100644 apps/web/app/groups/[id]/page.jsx.bak create mode 100644 apps/web/app/groups/page.jsx create mode 100644 apps/web/app/playlist/page.jsx create mode 100644 apps/web/app/profile/page.jsx create mode 100644 apps/web/app/settings/account/page.jsx create mode 100644 apps/web/app/settings/layout.jsx create mode 100644 apps/web/app/settings/notifications/page.jsx create mode 100644 apps/web/app/settings/page.jsx create mode 100644 apps/web/app/settings/privacy/page.jsx create mode 100644 apps/web/app/settings/profile/page.jsx create mode 100644 apps/web/components/AddFriendsModal.jsx create mode 100644 apps/web/components/Dashboard.jsx create mode 100644 apps/web/components/DeleteAccountModal.jsx create mode 100644 apps/web/components/FriendRequestsModal.jsx create mode 100644 apps/web/components/HomePage.jsx create mode 100644 apps/web/components/NotificationToggle.jsx create mode 100644 apps/web/components/PrivacyToggle.jsx create mode 100644 apps/web/components/ProfilePictureUpload.jsx create mode 100644 apps/web/components/QueryProvider.jsx create mode 100644 apps/web/components/SaveStatusIndicator.jsx create mode 100644 apps/web/components/SettingsConflictDialog.jsx create mode 100644 apps/web/components/SettingsNav.jsx create mode 100644 apps/web/components/SettingsPageWrapper.jsx create mode 100644 apps/web/components/SettingsSyncIndicator.jsx create mode 100644 apps/web/components/SongSearchModal.jsx create mode 100644 apps/web/components/ValidationError.jsx create mode 100644 apps/web/components/__tests__/AddFriendsModal.test.jsx create mode 100644 apps/web/components/__tests__/FriendRequestsModal.test.jsx create mode 100644 apps/web/components/__tests__/HomePage.test.jsx create mode 100644 apps/web/components/__tests__/LibraryView.test.jsx create mode 100644 apps/web/components/__tests__/Navbar.test.jsx create mode 100644 apps/web/components/__tests__/SongSearchModal.test.jsx create mode 100644 apps/web/components/__tests__/shared-components.test.jsx create mode 100644 apps/web/components/common/ImageWithFallback.jsx create mode 100644 apps/web/components/common/VybeLogo.jsx create mode 100644 apps/web/components/shared/CommunitiesDialog.jsx create mode 100644 apps/web/components/shared/EmptyState.jsx create mode 100644 apps/web/components/shared/FormField.jsx create mode 100644 apps/web/components/shared/FullGroupCard.jsx create mode 100644 apps/web/components/shared/GlassCard.jsx create mode 100644 apps/web/components/shared/GroupCard.jsx create mode 100644 apps/web/components/shared/LoadingState.jsx create mode 100644 apps/web/components/shared/ShareSongDialog.jsx create mode 100644 apps/web/components/shared/SongDetailsDialog.jsx create mode 100644 apps/web/components/ui/alert.jsx create mode 100644 apps/web/components/ui/avatar.jsx create mode 100644 apps/web/components/ui/badge.jsx create mode 100644 apps/web/components/ui/button.jsx create mode 100644 apps/web/components/ui/card.jsx create mode 100644 apps/web/components/ui/dialog.jsx create mode 100644 apps/web/components/ui/input.jsx create mode 100644 apps/web/components/ui/label.jsx create mode 100644 apps/web/components/ui/select.jsx create mode 100644 apps/web/components/ui/sonner.jsx create mode 100644 apps/web/components/ui/switch.jsx create mode 100644 apps/web/components/ui/textarea.jsx create mode 100644 apps/web/components/ui/utils.js create mode 100644 apps/web/config/constants.js create mode 100644 apps/web/hooks/useAutoSave.js create mode 100644 apps/web/hooks/useDialog.js create mode 100644 apps/web/hooks/useGroups.js create mode 100644 apps/web/hooks/useNotificationPreferences.js create mode 100644 apps/web/hooks/usePrivacySettings.js create mode 100644 apps/web/hooks/useProfileUpdate.js create mode 100644 apps/web/hooks/useSettingsMigration.js create mode 100644 apps/web/hooks/useSettingsSync.js create mode 100644 apps/web/hooks/useSettingsValidation.js create mode 100644 apps/web/hooks/useSocial.js create mode 100644 apps/web/lib/cache/settingsCache.js create mode 100644 apps/web/lib/getUserProvider.js create mode 100644 apps/web/lib/migrations/create_notification_preferences_table.sql create mode 100644 apps/web/lib/migrations/create_privacy_settings_table.sql create mode 100644 apps/web/lib/migrations/settingsMigrations.js create mode 100644 apps/web/lib/privacy/enforcer.js create mode 100644 apps/web/lib/schemas/notificationSchema.js create mode 100644 apps/web/lib/schemas/privacySchema.js create mode 100644 apps/web/lib/schemas/profileSchema.js create mode 100644 apps/web/lib/services/accountDeletion.js rename apps/web/{app => }/lib/spotify.js (94%) create mode 100644 apps/web/lib/utils/sanitization.js create mode 100644 apps/web/lib/utils/settingsConflictResolver.js create mode 100644 apps/web/lib/validation/serverValidation.js create mode 100644 apps/web/lib/youtube.js create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/sprint3.txt create mode 100644 apps/web/store/settingsStore.js create mode 100644 apps/web/supabase/migrations/002_create_songs_of_the_day_table.sql create mode 100644 apps/web/supabase/migrations/003_create_friendships.sql create mode 100644 apps/web/supabase/migrations/004_update_users_table.sql create mode 100644 apps/web/supabase/migrations/005_update_groups_table.sql create mode 100644 apps/web/supabase/migrations/006_create_group_playlists.sql create mode 100644 apps/web/supabase/migrations/007_add_last_used_provider.sql create mode 100644 apps/web/supabase/migrations/008_ensure_updated_at_column.sql create mode 100644 apps/web/test/helpers.js create mode 100644 apps/web/test/setup.ts create mode 100644 apps/web/test/test-utils.jsx create mode 100644 apps/web/test/utils.test.js create mode 100644 apps/web/tests/e2e/app.spec.ts create mode 100644 apps/web/tests/e2e/sign-out.spec.ts create mode 100644 apps/web/utils/clipboard.js create mode 100644 apps/web/vitest.config.ts diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore new file mode 100644 index 0000000..e31c206 --- /dev/null +++ b/apps/web/.eslintignore @@ -0,0 +1,9 @@ +# Ignore generated files and build outputs +node_modules/ +.next/ +out/ +build/ +playwright-report/ +test-results/ +next-env.d.ts +*.tsbuildinfo diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000..fb33731 --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "ignorePatterns": [ + "playwright-report/**", + "test-results/**", + "node_modules/**", + ".next/**", + "out/**", + "build/**" + ] +} + diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a52..2e2dd79 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -12,6 +12,9 @@ # testing /coverage +/test-results +/playwright-report +/playwright/.cache # next.js /.next/ @@ -23,6 +26,8 @@ # misc .DS_Store *.pem +Thumbs.db +Desktop.ini # debug npm-debug.log* @@ -32,6 +37,10 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +.env.local +.env.development.local +.env.test.local +.env.production.local # vercel .vercel @@ -39,3 +48,21 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +Thumbs.db + +# Testing outputs +/coverage/ +*.lcov +.nyc_output/ diff --git a/apps/web/ADD_YOUTUBE_URL.sql b/apps/web/ADD_YOUTUBE_URL.sql new file mode 100644 index 0000000..d57f303 --- /dev/null +++ b/apps/web/ADD_YOUTUBE_URL.sql @@ -0,0 +1,11 @@ +-- Add youtube_url column to existing songs_of_the_day table +-- Run this in your Supabase SQL Editor if you already created the table + +ALTER TABLE songs_of_the_day +ADD COLUMN IF NOT EXISTS youtube_url TEXT; + +-- Success message +DO $$ +BEGIN + RAISE NOTICE 'Successfully added youtube_url column to songs_of_the_day table!'; +END $$; diff --git a/apps/web/SETUP_DATABASE.sql b/apps/web/SETUP_DATABASE.sql new file mode 100644 index 0000000..4630c69 --- /dev/null +++ b/apps/web/SETUP_DATABASE.sql @@ -0,0 +1,83 @@ +-- Complete Database Setup for Vybe +-- Run this in your Supabase SQL Editor + +-- ============================================ +-- 1. CREATE SONGS_OF_THE_DAY TABLE +-- ============================================ +CREATE TABLE IF NOT EXISTS songs_of_the_day ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + song_id TEXT NOT NULL, + song_name TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT, + image_url TEXT, + preview_url TEXT, + spotify_url TEXT, + youtube_url TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create index on user_id and created_at for faster queries +CREATE INDEX IF NOT EXISTS idx_songs_of_the_day_user_created +ON songs_of_the_day(user_id, created_at DESC); + +-- Enable RLS +ALTER TABLE songs_of_the_day ENABLE ROW LEVEL SECURITY; + +-- Drop existing policies if they exist +DROP POLICY IF EXISTS "Users can view their own songs of the day" ON songs_of_the_day; +DROP POLICY IF EXISTS "Users can view friends songs of the day" ON songs_of_the_day; +DROP POLICY IF EXISTS "Users can insert their own songs of the day" ON songs_of_the_day; +DROP POLICY IF EXISTS "Users can update their own songs of the day" ON songs_of_the_day; +DROP POLICY IF EXISTS "Users can delete their own songs of the day" ON songs_of_the_day; + +-- Policy: Users can view their own songs of the day +CREATE POLICY "Users can view their own songs of the day" +ON songs_of_the_day +FOR SELECT +USING (auth.uid() = user_id); + +-- Policy: Users can view friends' songs of the day +CREATE POLICY "Users can view friends songs of the day" +ON songs_of_the_day +FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM friendships + WHERE ( + (friendships.user_id = auth.uid() AND friendships.friend_id = songs_of_the_day.user_id) + OR + (friendships.friend_id = auth.uid() AND friendships.user_id = songs_of_the_day.user_id) + ) + AND friendships.status = 'accepted' + ) +); + +-- Policy: Users can insert their own songs of the day +CREATE POLICY "Users can insert their own songs of the day" +ON songs_of_the_day +FOR INSERT +WITH CHECK (auth.uid() = user_id); + +-- Policy: Users can update their own songs of the day +CREATE POLICY "Users can update their own songs of the day" +ON songs_of_the_day +FOR UPDATE +USING (auth.uid() = user_id) +WITH CHECK (auth.uid() = user_id); + +-- Policy: Users can delete their own songs of the day +CREATE POLICY "Users can delete their own songs of the day" +ON songs_of_the_day +FOR DELETE +USING (auth.uid() = user_id); + +-- Success message +DO $$ +BEGIN + RAISE NOTICE 'Database setup completed successfully!'; + RAISE NOTICE 'Created tables: songs_of_the_day'; + RAISE NOTICE 'All RLS policies have been applied.'; +END $$; diff --git a/apps/web/TESTING.md b/apps/web/TESTING.md new file mode 100644 index 0000000..729ef13 --- /dev/null +++ b/apps/web/TESTING.md @@ -0,0 +1,135 @@ +# Testing Setup + +This project uses a comprehensive testing framework with both unit tests and end-to-end tests. + +## Testing Stack + +- **Vitest**: Fast unit testing framework with React Testing Library +- **Playwright**: End-to-end testing for browser automation +- **React Testing Library**: Component testing utilities +- **Jest DOM**: Custom matchers for DOM testing + +## Running Tests + +### Unit Tests + +```bash +# Run all unit tests +npm test + +# Run tests in watch mode +npm run test + +# Run tests once (CI mode) +npm run test:run + +# Open Vitest UI +npm run test:ui +``` + +### End-to-End Tests + +```bash +# Run Playwright tests +npm run test:e2e + +# Run Playwright tests in headed mode +npm run test:e2e:headed + +# Run Playwright tests in debug mode +npm run test:e2e:debug +``` + +### All Tests + +```bash +# Run both unit and E2E tests +npm run test:all +``` + +## Test Structure + +```text +apps/web/ +├── components/ +│ └── __tests__/ # Component unit tests +│ ├── Navbar.test.jsx +│ └── LibraryView.test.jsx +├── test/ +│ ├── setup.ts # Test setup configuration +│ ├── helpers.js # Test helper utilities +│ └── utils.test.js # Utility function tests +├── tests/ +│ └── e2e/ # End-to-end tests +│ └── app.spec.ts +├── vitest.config.ts # Vitest configuration +└── playwright.config.ts # Playwright configuration +``` + +## Writing Tests + +### Unit Test Guidelines + +- Use `describe` and `it` blocks for test organization +- Import testing utilities from `@testing-library/react` +- Mock external dependencies using `vi.mock()` +- Test component rendering, user interactions, and state changes + +### End-to-End Test Guidelines + +- Use Playwright's `test` and `expect` functions +- Test complete user workflows +- Verify page navigation and interactions +- Test responsive design across different viewports + +## Configuration + +### Vitest Configuration + +- Configured with React plugin and jsdom environment +- Includes path aliases for `@/` imports +- Setup file includes Jest DOM matchers and act warning suppression +- Enhanced ESM compatibility with esbuild configuration +- CSS processing enabled for component testing + +### Playwright Configuration + +- Configured for Chrome, Firefox, and Safari +- Automatically starts dev server before tests with 2-minute timeout +- Includes trace collection for debugging +- Robust error handling with stdout/stderr piping + +## Demo Tests Included + +1. **Navbar Component**: Tests brand rendering, navigation links, and active states +2. **LibraryView Component**: Tests Spotify integration, tab switching, and data loading +3. **Utility Functions**: Tests time formatting helper functions +4. **E2E App Flow**: Tests homepage, navigation, and responsive design + +## Test Results & Performance + +- **Unit Tests**: 20 tests passing (comprehensive component and utility coverage) +- **E2E Tests**: 4 comprehensive tests covering navigation and responsive design +- **Performance**: Tests run efficiently with proper mocking and act warning suppression +- **Maintainability**: Centralized test helpers and parameterized test cases + +## Best Practices + +- Write tests that focus on user behavior rather than implementation details +- Use meaningful test descriptions +- Mock external API calls and dependencies +- Test both happy paths and error scenarios +- Keep tests isolated and independent +- Use data-testid attributes for reliable element selection when needed +- Leverage test helpers for consistent mock data and selectors +- Use parameterized tests for comprehensive coverage of utility functions + +## Recent Improvements + +- ✅ Fixed all markdown linting issues (15 → 0) +- ✅ Enhanced test configuration with better ESM compatibility +- ✅ Added act warning suppression for cleaner test output +- ✅ Improved E2E tests with proper wait states and network idle checks +- ✅ Created centralized test helpers for better maintainability +- ✅ Added comprehensive test scripts for different scenarios +- ✅ Enhanced utility tests with parameterized test cases diff --git a/apps/web/app/(auth)/__tests__/sign-in.test.jsx b/apps/web/app/(auth)/__tests__/sign-in.test.jsx new file mode 100644 index 0000000..3d35ed9 --- /dev/null +++ b/apps/web/app/(auth)/__tests__/sign-in.test.jsx @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useRouter } from 'next/navigation'; +import SignInPage from '../sign-in/page'; +import { supabaseBrowser } from '@/lib/supabase/client'; + +// Mock Next.js router +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(), +})); + +// Mock Supabase client +vi.mock('@/lib/supabase/client', () => ({ + supabaseBrowser: vi.fn(), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Music: ({ className }) =>
Music
, +})); + +describe('SignInPage', () => { + const mockPush = vi.fn(); + const mockGetSession = vi.fn(); + const mockSignInWithOAuth = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + useRouter.mockReturnValue({ + push: mockPush, + }); + + supabaseBrowser.mockReturnValue({ + auth: { + getSession: mockGetSession, + signInWithOAuth: mockSignInWithOAuth, + }, + }); + + // Mock window.location + delete window.location; + window.location = { origin: 'http://localhost:3000' }; + }); + + it('renders sign-in page with welcome message', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Welcome to Vybe')).toBeInTheDocument(); + expect(screen.getByText('Connect with friends and share your musical journey')).toBeInTheDocument(); + }); + }); + + it('renders Music icon', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('music-icon')).toBeInTheDocument(); + }); + }); + + it('renders Spotify and Google sign-in buttons', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + render(); + + await waitFor(() => { + const spotifyButton = screen.getByTestId('spotify-signin'); + const googleButton = screen.getByTestId('google-signin'); + + expect(spotifyButton).toBeInTheDocument(); + expect(googleButton).toBeInTheDocument(); + expect(spotifyButton).toHaveTextContent('Continue with Spotify'); + expect(googleButton).toHaveTextContent('Continue with Google'); + }); + }); + + it('redirects to library if user is already logged in', async () => { + mockGetSession.mockResolvedValue({ + data: { + session: { + user: { id: '123' }, + }, + }, + }); + + render(); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/library'); + }); + }); + + it('calls signInWithOAuth with correct Spotify parameters when Spotify button is clicked', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + mockSignInWithOAuth.mockResolvedValue({ error: null }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('spotify-signin')).toBeInTheDocument(); + }); + + const spotifyButton = screen.getByTestId('spotify-signin'); + await userEvent.click(spotifyButton); + + expect(mockSignInWithOAuth).toHaveBeenCalledWith({ + provider: 'spotify', + options: { + redirectTo: 'http://localhost:3000/auth/callback?next=/library&provider=spotify', + scopes: 'user-read-email user-read-private playlist-read-private user-read-recently-played', + }, + queryParams: { show_dialog: 'true' }, + }); + }); + + it('calls signInWithOAuth with correct Google parameters when Google button is clicked', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + mockSignInWithOAuth.mockResolvedValue({ error: null }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('google-signin')).toBeInTheDocument(); + }); + + const googleButton = screen.getByTestId('google-signin'); + await userEvent.click(googleButton); + + expect(mockSignInWithOAuth).toHaveBeenCalledWith({ + provider: 'google', + options: { + redirectTo: 'http://localhost:3000/auth/callback?next=/library&provider=google', + scopes: 'openid email profile https://www.googleapis.com/auth/youtube.readonly', + }, + }); + }); + + it('handles Spotify OAuth errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGetSession.mockResolvedValue({ data: { session: null } }); + mockSignInWithOAuth.mockResolvedValue({ + error: { message: 'OAuth error' }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('spotify-signin')).toBeInTheDocument(); + }); + + const spotifyButton = screen.getByTestId('spotify-signin'); + await userEvent.click(spotifyButton); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Spotify login error:', 'OAuth error'); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('handles Google OAuth errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGetSession.mockResolvedValue({ data: { session: null } }); + mockSignInWithOAuth.mockResolvedValue({ + error: { message: 'OAuth error' }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('google-signin')).toBeInTheDocument(); + }); + + const googleButton = screen.getByTestId('google-signin'); + await userEvent.click(googleButton); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Google/YouTube login error:', 'OAuth error'); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('has correct styling classes for Spotify button', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + render(); + + await waitFor(() => { + const spotifyButton = screen.getByTestId('spotify-signin'); + expect(spotifyButton).toHaveClass('bg-[#1DB954]'); + expect(spotifyButton).toHaveClass('hover:bg-[#1ed760]'); + expect(spotifyButton).toHaveClass('active:bg-[#1aa34a]'); + }); + }); + + it('has correct styling classes for Google button', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + render(); + + await waitFor(() => { + const googleButton = screen.getByTestId('google-signin'); + expect(googleButton).toHaveClass('bg-white'); + expect(googleButton).toHaveClass('hover:bg-gray-100'); + expect(googleButton).toHaveClass('border'); + }); + }); + + it('renders glass card container', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + render(); + + await waitFor(() => { + const container = screen.getByText('Welcome to Vybe').closest('.glass-card'); + expect(container).toBeInTheDocument(); + }); + }); + + it('renders responsive classes for mobile', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + + render(); + + await waitFor(() => { + const spotifyButton = screen.getByTestId('spotify-signin'); + expect(spotifyButton).toHaveClass('text-sm'); + expect(spotifyButton).toHaveClass('sm:text-base'); + expect(spotifyButton).toHaveClass('touch-manipulation'); + }); + }); +}); + diff --git a/apps/web/app/(auth)/sign-in/page.jsx b/apps/web/app/(auth)/sign-in/page.jsx index afd87ce..f63face 100644 --- a/apps/web/app/(auth)/sign-in/page.jsx +++ b/apps/web/app/(auth)/sign-in/page.jsx @@ -1,15 +1,30 @@ 'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { supabaseBrowser } from '@/lib/supabase/client'; +import { Music } from 'lucide-react'; export default function SignInPage() { const supabase = supabaseBrowser(); + const router = useRouter(); + + useEffect(() => { + // Check if user is already logged in + async function checkAuth() { + const { data: { session } } = await supabase.auth.getSession(); + if (session) { + router.push('/library'); + } + } + checkAuth(); + }, [router]); const signInWithSpotify = async () => { const { error } = await supabase.auth.signInWithOAuth({ provider: 'spotify', options: { - redirectTo: `${location.origin}/auth/callback?next=/library`, + redirectTo: `${location.origin}/auth/callback?next=/library&provider=spotify`, scopes: 'user-read-email user-read-private playlist-read-private user-read-recently-played', }, queryParams: { show_dialog: 'true' }, @@ -17,21 +32,64 @@ export default function SignInPage() { if (error) console.error('Spotify login error:', error.message); }; + const signInWithGoogle = async () => { + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: `${location.origin}/auth/callback?next=/library&provider=google`, + scopes: [ + 'openid', + 'email', + 'profile', + 'https://www.googleapis.com/auth/youtube.readonly', + ].join(' '), + }, + }); + if (error) console.error('Google/YouTube login error:', error.message); + }; + return ( -
-
-

Welcome to Vybe

-

Connect with friends and share your musical journey

-
-
-
- -
+
+
+
+
+
+
+
+ +
+
+
+

Welcome to Vybe

+

Connect with friends and share your musical journey

+
+ +
+ + + +
); diff --git a/apps/web/app/api/friends/requests/route.js b/apps/web/app/api/friends/requests/route.js new file mode 100644 index 0000000..dc6eb28 --- /dev/null +++ b/apps/web/app/api/friends/requests/route.js @@ -0,0 +1,162 @@ +// app/api/friends/requests/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get pending friend requests (both sent and received) + const { data: friendships, error: requestsError } = await supabase + .from('friendships') + .select('id, user_id, friend_id, status, created_at') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`) + .eq('status', 'pending'); + + if (requestsError) { + console.error('Error fetching friend requests:', requestsError); + return NextResponse.json({ error: 'Failed to fetch friend requests' }, { status: 500 }); + } + + // Categorize requests + const sent = []; + const received = []; + + // Get all unique friend IDs + const friendIds = [...new Set(friendships.map(f => f.user_id === user.id ? f.friend_id : f.user_id))]; + + // Fetch user details from the public users table + for (const friendId of friendIds) { + const { data: friendUser } = await supabase + .from('users') + .select('id, username, display_name') + .eq('id', friendId) + .single(); + + if (friendUser) { + const friendship = friendships.find(f => + (f.user_id === user.id && f.friend_id === friendId) || + (f.user_id === friendId && f.friend_id === user.id) + ); + + if (friendship) { + const friendInfo = { + id: friendUser.id, + email: '', + name: friendUser.display_name || friendUser.username, + username: friendUser.username, + friendship_id: friendship.id, + created_at: friendship.created_at + }; + + if (friendship.user_id === user.id) { + sent.push(friendInfo); + } else { + received.push(friendInfo); + } + } + } + } + + return NextResponse.json({ + success: true, + sent, + received + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PATCH(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { friendshipId, action } = body; + + if (!friendshipId || !action) { + return NextResponse.json({ error: 'Friendship ID and action are required' }, { status: 400 }); + } + + if (!['accept', 'reject'].includes(action)) { + return NextResponse.json({ error: 'Action must be accept or reject' }, { status: 400 }); + } + + // Check if friendship exists and user is the recipient + const { data: friendship, error: checkError } = await supabase + .from('friendships') + .select('id, user_id, friend_id, status') + .eq('id', friendshipId) + .eq('status', 'pending') + .single(); + + if (checkError || !friendship) { + return NextResponse.json({ error: 'Friend request not found or already processed' }, { status: 404 }); + } + + // Verify user is the recipient (friend_id) + if (friendship.friend_id !== user.id) { + return NextResponse.json({ error: 'Unauthorized to perform this action' }, { status: 403 }); + } + + if (action === 'accept') { + // Update status to accepted + const { error: updateError } = await supabase + .from('friendships') + .update({ + status: 'accepted', + updated_at: new Date().toISOString() + }) + .eq('id', friendshipId); + + if (updateError) { + console.error('Error accepting friend request:', updateError); + return NextResponse.json({ error: 'Failed to accept friend request' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Friend request accepted' + }); + + } else if (action === 'reject') { + // Delete the friendship record + const { error: deleteError } = await supabase + .from('friendships') + .delete() + .eq('id', friendshipId); + + if (deleteError) { + console.error('Error rejecting friend request:', deleteError); + return NextResponse.json({ error: 'Failed to reject friend request' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Friend request rejected' + }); + } + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/friends/route.js b/apps/web/app/api/friends/route.js new file mode 100644 index 0000000..080877c --- /dev/null +++ b/apps/web/app/api/friends/route.js @@ -0,0 +1,213 @@ +// app/api/friends/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get all accepted friends for the current user + const { data: friendships, error: friendsError } = await supabase + .from('friendships') + .select(` + id, + user_id, + friend_id, + status, + created_at + `) + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`) + .eq('status', 'accepted'); + + if (friendsError) { + console.error('Error fetching friends:', friendsError); + return NextResponse.json({ error: 'Failed to fetch friends' }, { status: 500 }); + } + + console.log('Friendships found:', friendships); + + // Get unique friend IDs + const friendIds = [...new Set(friendships.map(f => f.user_id === user.id ? f.friend_id : f.user_id))]; + + // Fetch user details from the public users table + const friends = []; + for (const friendId of friendIds) { + const { data: friendUser } = await supabase + .from('users') + .select('id, username, display_name') + .eq('id', friendId) + .single(); + + if (friendUser) { + const friendship = friendships.find(f => f.user_id === friendId || f.friend_id === friendId); + friends.push({ + id: friendUser.id, + email: '', + name: friendUser.display_name || friendUser.username, + username: friendUser.username, + friendship_id: friendship?.id, + created_at: friendship?.created_at + }); + } + } + + return NextResponse.json({ + success: true, + friends + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { friendId } = body; + + console.log('POST /api/friends - friendId:', friendId); + + if (!friendId) { + return NextResponse.json({ error: 'Friend ID is required' }, { status: 400 }); + } + + if (friendId === user.id) { + return NextResponse.json({ error: 'You cannot send a friend request to yourself' }, { status: 400 }); + } + + // Check if user exists in the public users table (don't need admin for this) + const { data: friendUser, error: userError } = await supabase + .from('users') + .select('id') + .eq('id', friendId) + .single(); + + if (userError || !friendUser) { + console.error('User not found in users table:', userError); + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Check if friendship already exists (in either direction) + const { data: existingFriendships } = await supabase + .from('friendships') + .select('id, status') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`); + + // Filter to check if there's a friendship with the specific friend + const existingFriendship = existingFriendships?.find(f => + (f.user_id === user.id && f.friend_id === friendId) || + (f.user_id === friendId && f.friend_id === user.id) + ); + + if (existingFriendship) { + if (existingFriendship.status === 'accepted') { + return NextResponse.json({ error: 'You are already friends' }, { status: 400 }); + } else if (existingFriendship.status === 'pending') { + return NextResponse.json({ error: 'Friend request already sent or pending' }, { status: 400 }); + } + } + + // Create friend request (user_id is sender, friend_id is receiver) + console.log('Creating friend request:', { user_id: user.id, friend_id: friendId }); + + const { data: friendship, error: friendshipError } = await supabase + .from('friendships') + .insert({ + user_id: user.id, + friend_id: friendId, + status: 'pending' + }) + .select() + .single(); + + if (friendshipError) { + console.error('Error creating friend request:', friendshipError); + return NextResponse.json({ error: 'Failed to send friend request', details: friendshipError }, { status: 500 }); + } + + console.log('✅ Friend request created successfully in database:', { + id: friendship.id, + user_id: friendship.user_id, + friend_id: friendship.friend_id, + status: friendship.status + }); + + // Verify it was saved by querying it back + const { data: verify } = await supabase + .from('friendships') + .select('*') + .eq('id', friendship.id) + .single(); + + console.log('🔍 Verified in database:', verify ? 'EXISTS' : 'NOT FOUND'); + + return NextResponse.json({ + success: true, + message: 'Friend request sent', + friendship + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { friendId } = body; + + if (!friendId) { + return NextResponse.json({ error: 'Friend ID is required' }, { status: 400 }); + } + + // Delete the friendship (check both directions) + const { error: deleteError } = await supabase + .from('friendships') + .delete() + .or(`and(user_id.eq.${user.id},friend_id.eq.${friendId}),and(user_id.eq.${friendId},friend_id.eq.${user.id})`); + + if (deleteError) { + console.error('Error removing friend:', deleteError); + return NextResponse.json({ error: 'Failed to remove friend' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Friend removed successfully' + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/groups/route.js b/apps/web/app/api/groups/route.js new file mode 100644 index 0000000..c31ceeb --- /dev/null +++ b/apps/web/app/api/groups/route.js @@ -0,0 +1,130 @@ +// app/api/groups/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +// Generate a unique group code +function generateGroupCode() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export async function POST(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { name, description } = body; + + if (!name || name.trim().length === 0) { + return NextResponse.json({ error: 'Group name is required' }, { status: 400 }); + } + + // Create the group (join_code will be auto-generated by database trigger) + const { data: group, error: groupError } = await supabase + .from('groups') + .insert({ + name: name.trim(), + description: description?.trim() || null, + owner_id: user.id, + }) + .select() + .single(); + + if (groupError) { + console.error('Error creating group:', groupError); + return NextResponse.json({ error: 'Failed to create group' }, { status: 500 }); + } + + // Add the creator as a member of the group + const { error: memberError } = await supabase + .from('group_members') + .insert({ + group_id: group.id, + user_id: user.id, + role: 'admin', + joined_at: new Date().toISOString(), + }); + + if (memberError) { + console.error('Error adding creator as member:', memberError); + // Don't fail the request, just log the error + } + + return NextResponse.json({ + success: true, + group: { + id: group.id, + name: group.name, + description: group.description, + join_code: group.join_code, + created_at: group.created_at, + } + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get groups where user is a member + const { data: groups, error: groupsError } = await supabase + .from('group_members') + .select(` + group_id, + role, + joined_at, + groups ( + id, + name, + description, + join_code, + created_at, + owner_id + ) + `) + .eq('user_id', user.id) + .order('joined_at', { ascending: false }); + + if (groupsError) { + console.error('Error fetching groups:', groupsError); + return NextResponse.json({ error: 'Failed to fetch groups' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + groups: groups.map(member => ({ + ...member.groups, + role: member.role, + joined_at: member.joined_at, + })) + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/history/route.js b/apps/web/app/api/history/route.js new file mode 100644 index 0000000..c01bb5e --- /dev/null +++ b/apps/web/app/api/history/route.js @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { supabaseRoute } from '@/lib/supabase/route'; + +// GET /api/history?limit=20&before=ISO_DATE +export async function GET(req) { + try { + const { searchParams } = new URL(req.url); + const limit = Math.min(parseInt(searchParams.get('limit') || '20', 10), 100); + const before = searchParams.get('before'); // ISO string or timestamp + + const sb = supabaseRoute(); + const { data: { session } } = await sb.auth.getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let query = sb + .from('play_history') + .select('*') + .eq('user_id', session.user.id) + .order('played_at', { ascending: false }) + .limit(limit); + + if (before) { + const beforeDate = new Date(isNaN(Number(before)) ? before : Number(before)); + if (!isNaN(beforeDate.getTime())) { + query = query.lt('played_at', beforeDate.toISOString()); + } + } + + const { data, error } = await query; + if (error) throw error; + + return NextResponse.json({ items: data || [] }); + } catch (err) { + console.error('[api/history] error', err); + return NextResponse.json({ error: String(err?.message || err) }, { status: 500 }); + } +} + + diff --git a/apps/web/app/api/import-playlist/route.js b/apps/web/app/api/import-playlist/route.js new file mode 100644 index 0000000..67f00d7 --- /dev/null +++ b/apps/web/app/api/import-playlist/route.js @@ -0,0 +1,341 @@ +import { NextResponse } from 'next/server'; +import { supabaseServer } from '@/lib/supabase/server'; +import { getValidAccessToken } from '@/lib/youtube'; + +export async function POST(request) { + try { + const supabase = supabaseServer(); + const { data: { session }, error: sessionError } = await supabase.auth.getSession(); + + if (sessionError || !session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { groupId, platform, playlistUrl, userId } = body; + + if (!groupId || !platform || !playlistUrl) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Verify user is a member of the group + const { data: group } = await supabase + .from('groups') + .select('owner_id') + .eq('id', groupId) + .single(); + + if (!group) { + return NextResponse.json({ error: 'Group not found' }, { status: 404 }); + } + + const isMember = group.owner_id === session.user.id || await checkGroupMembership(supabase, groupId, session.user.id); + + if (!isMember) { + return NextResponse.json({ error: 'Not a member of this group' }, { status: 403 }); + } + + // Check if user already has a playlist in this group + const { data: existingPlaylists } = await supabase + .from('group_playlists') + .select('id') + .eq('group_id', groupId) + .eq('added_by', session.user.id); + + // If user already has a playlist, delete it (cascade will delete songs) + if (existingPlaylists && existingPlaylists.length > 0) { + console.log(`[import-playlist] User already has ${existingPlaylists.length} playlist(s), removing old ones...`); + + for (const oldPlaylist of existingPlaylists) { + await supabase + .from('group_playlists') + .delete() + .eq('id', oldPlaylist.id); + } + } + + // Import playlist based on platform + let playlistData; + if (platform === 'youtube') { + playlistData = await importYouTubePlaylist(supabase, playlistUrl, session.user.id); + } else if (platform === 'spotify') { + playlistData = await importSpotifyPlaylist(supabase, playlistUrl, session.user.id); + } else { + return NextResponse.json({ error: 'Invalid platform' }, { status: 400 }); + } + + // Create group_playlist entry + const { data: groupPlaylist, error: playlistError } = await supabase + .from('group_playlists') + .insert({ + group_id: groupId, + name: playlistData.name, + platform, + playlist_url: playlistUrl, + playlist_id: playlistData.id, + track_count: playlistData.tracks.length, + added_by: session.user.id, + }) + .select() + .single(); + + if (playlistError) { + console.error('Error creating group playlist:', playlistError); + return NextResponse.json({ error: 'Failed to create playlist' }, { status: 500 }); + } + + // Insert all songs + const songs = playlistData.tracks.map((track, index) => ({ + playlist_id: groupPlaylist.id, + title: track.title, + artist: track.artist, + duration: track.duration, + thumbnail_url: track.thumbnail, + external_id: track.id, + position: index, + })); + + const { error: songsError } = await supabase + .from('playlist_songs') + .insert(songs); + + if (songsError) { + console.error('Error inserting songs:', songsError); + // Clean up the playlist if songs failed to insert + await supabase.from('group_playlists').delete().eq('id', groupPlaylist.id); + return NextResponse.json({ error: 'Failed to import songs' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + playlist: groupPlaylist, + trackCount: songs.length, + }); + + } catch (error) { + console.error('Error importing playlist:', error); + return NextResponse.json( + { error: error.message || 'Failed to import playlist' }, + { status: 500 } + ); + } +} + +async function checkGroupMembership(supabase, groupId, userId) { + const { data } = await supabase + .from('group_members') + .select('user_id') + .eq('group_id', groupId) + .eq('user_id', userId) + .single(); + + return !!data; +} + +async function importYouTubePlaylist(supabase, playlistUrl, userId) { + // Extract playlist ID from URL + const playlistId = extractYouTubePlaylistId(playlistUrl); + if (!playlistId) { + throw new Error('Invalid YouTube playlist URL'); + } + + // Get access token + const accessToken = await getValidAccessToken(supabase, userId); + + // Fetch playlist details + const playlistResponse = await fetch( + `https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=${playlistId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!playlistResponse.ok) { + throw new Error('Failed to fetch YouTube playlist'); + } + + const playlistData = await playlistResponse.json(); + if (!playlistData.items || playlistData.items.length === 0) { + throw new Error('Playlist not found'); + } + + const playlist = playlistData.items[0]; + + // Fetch all playlist items (videos) + const tracks = []; + let nextPageToken = null; + let pageCount = 0; + + do { + const itemsUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=${playlistId}&maxResults=50${nextPageToken ? `&pageToken=${nextPageToken}` : ''}`; + + console.log(`[YouTube Import] Fetching page ${pageCount + 1} for playlist ${playlistId}`); + + const itemsResponse = await fetch(itemsUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!itemsResponse.ok) { + const errorText = await itemsResponse.text(); + console.error(`[YouTube Import] Failed to fetch page ${pageCount + 1}:`, errorText); + throw new Error('Failed to fetch playlist items'); + } + + const itemsData = await itemsResponse.json(); + console.log(`[YouTube Import] Page ${pageCount + 1}: Found ${itemsData.items?.length || 0} items`); + + // Filter out videos without valid IDs first + const validItems = itemsData.items.filter(item => + item.contentDetails?.videoId && + item.snippet?.title !== 'Private video' && + item.snippet?.title !== 'Deleted video' + ); + + // Get video durations (requires separate API call) + if (validItems.length > 0) { + const videoIds = validItems.map(item => item.contentDetails.videoId).join(','); + const videosResponse = await fetch( + `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const videosData = await videosResponse.json(); + const durationMap = {}; + videosData.items?.forEach(video => { + durationMap[video.id] = parseYouTubeDuration(video.contentDetails.duration); + }); + + validItems.forEach(item => { + tracks.push({ + id: item.contentDetails.videoId, + title: item.snippet.title, + artist: item.snippet.videoOwnerChannelTitle || 'Unknown', + thumbnail: item.snippet.thumbnails?.medium?.url || item.snippet.thumbnails?.default?.url, + duration: durationMap[item.contentDetails.videoId] || 0, + }); + }); + } + + nextPageToken = itemsData.nextPageToken; + pageCount++; + + console.log(`[YouTube Import] Total tracks so far: ${tracks.length}, Next page token: ${nextPageToken ? 'exists' : 'none'}`); + } while (nextPageToken); + + return { + id: playlistId, + name: playlist.snippet.title, + tracks, + }; +} + +async function importSpotifyPlaylist(supabase, playlistUrl, userId) { + // Extract playlist ID from URL + const playlistId = extractSpotifyPlaylistId(playlistUrl); + + // Validate playlist ID against Spotify spec (typically 22 chars, base62) + if (!playlistId || !isValidSpotifyPlaylistId(playlistId)) { + throw new Error('Invalid Spotify playlist URL or ID'); + } + + // Get Spotify access token + const { data: tokenData } = await supabase + .from('spotify_tokens') + .select('access_token') + .eq('user_id', userId) + .single(); + + if (!tokenData?.access_token) { + throw new Error('Spotify not connected'); + } + + // Fetch playlist details + const playlistResponse = await fetch( + `https://api.spotify.com/v1/playlists/${playlistId}`, + { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + } + ); + + if (!playlistResponse.ok) { + throw new Error('Failed to fetch Spotify playlist'); + } + + const playlistData = await playlistResponse.json(); + + // Fetch all tracks (handle pagination) + const tracks = []; + let nextUrl = playlistData.tracks.href; + + while (nextUrl) { + const tracksResponse = await fetch(nextUrl, { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (!tracksResponse.ok) { + throw new Error('Failed to fetch playlist tracks'); + } + + const tracksData = await tracksResponse.json(); + + tracksData.items.forEach(item => { + if (item.track) { + tracks.push({ + id: item.track.id, + title: item.track.name, + artist: item.track.artists.map(a => a.name).join(', '), + thumbnail: item.track.album.images[0]?.url, + duration: Math.floor(item.track.duration_ms / 1000), + }); + } + }); + + nextUrl = tracksData.next; + } + + return { + id: playlistId, + name: playlistData.name, + tracks, + }; +} + +function extractYouTubePlaylistId(url) { + const match = url.match(/[?&]list=([^&]+)/); + return match ? match[1] : null; +} + +function extractSpotifyPlaylistId(url) { + const match = url.match(/playlist\/([a-zA-Z0-9]+)/); + return match ? match[1] : null; +} + +// Validate Spotify playlist ID (base62, usually length 22) +function isValidSpotifyPlaylistId(id) { + // Spotify playlist IDs are 22 characters, base62: [A-Za-z0-9] + return typeof id === "string" && /^[A-Za-z0-9]{22}$/.test(id); +} + +function parseYouTubeDuration(duration) { + // Parse ISO 8601 duration format (PT1H2M3S) + const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return 0; + + const hours = parseInt(match[1] || 0); + const minutes = parseInt(match[2] || 0); + const seconds = parseInt(match[3] || 0); + + return hours * 3600 + minutes * 60 + seconds; +} diff --git a/apps/web/app/api/run-migration/route.js b/apps/web/app/api/run-migration/route.js new file mode 100644 index 0000000..c8da860 --- /dev/null +++ b/apps/web/app/api/run-migration/route.js @@ -0,0 +1,53 @@ +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function POST(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Check if user is authenticated + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Run the migration using raw SQL + const { data, error } = await supabase.rpc('exec_sql', { + sql: ` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'last_used_provider' + ) THEN + ALTER TABLE users ADD COLUMN last_used_provider TEXT; + END IF; + END $$; + + CREATE INDEX IF NOT EXISTS idx_users_last_used_provider ON users(last_used_provider); + ` + }); + + if (error) { + console.error('Migration error:', error); + return NextResponse.json({ + error: 'Migration failed', + details: error.message, + hint: 'Please run the migration manually in Supabase SQL Editor' + }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Migration completed successfully' + }); + } catch (err) { + console.error('Unexpected error:', err); + return NextResponse.json({ + error: 'Unexpected error', + details: err.message + }, { status: 500 }); + } +} diff --git a/apps/web/app/api/song-of-the-day/route.js b/apps/web/app/api/song-of-the-day/route.js new file mode 100644 index 0000000..fb227a2 --- /dev/null +++ b/apps/web/app/api/song-of-the-day/route.js @@ -0,0 +1,182 @@ +// app/api/song-of-the-day/route.js +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId') || user.id; + + // Get today's date at midnight (UTC) + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + // Get the user's song of the day for today + const { data: songOfDay, error } = await supabase + .from('songs_of_the_day') + .select('*') + .eq('user_id', userId) + .gte('created_at', today.toISOString()) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (error && error.code !== 'PGRST116') { + console.error('Error fetching song of the day:', error); + return NextResponse.json({ error: 'Failed to fetch song of the day' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + songOfDay: songOfDay || null, + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { songId, songName, artist, album, imageUrl, previewUrl, spotifyUrl, youtubeUrl } = body; + + if (!songId || !songName || !artist) { + return NextResponse.json({ error: 'Song information is required' }, { status: 400 }); + } + + // Get today's date at midnight (UTC) + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + // Check if user already has a song of the day for today + const { data: existing } = await supabase + .from('songs_of_the_day') + .select('id') + .eq('user_id', user.id) + .gte('created_at', today.toISOString()) + .maybeSingle(); + + if (existing) { + // Update existing song of the day + const { data: updated, error: updateError } = await supabase + .from('songs_of_the_day') + .update({ + song_id: songId, + song_name: songName, + artist, + album, + image_url: imageUrl, + preview_url: previewUrl, + spotify_url: spotifyUrl, + youtube_url: youtubeUrl, + updated_at: new Date().toISOString(), + }) + .eq('id', existing.id) + .select() + .single(); + + if (updateError) { + console.error('Error updating song of the day:', updateError); + return NextResponse.json({ error: 'Failed to update song of the day' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Song of the day updated', + songOfDay: updated, + }); + } else { + // Create new song of the day + const { data: created, error: createError } = await supabase + .from('songs_of_the_day') + .insert({ + user_id: user.id, + song_id: songId, + song_name: songName, + artist, + album, + image_url: imageUrl, + preview_url: previewUrl, + spotify_url: spotifyUrl, + youtube_url: youtubeUrl, + created_at: new Date().toISOString(), + }) + .select() + .single(); + + if (createError) { + console.error('Error creating song of the day:', createError); + return NextResponse.json({ error: 'Failed to create song of the day' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Song of the day set', + songOfDay: created, + }); + } + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get today's date at midnight (UTC) + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + // Delete today's song of the day + const { error: deleteError } = await supabase + .from('songs_of_the_day') + .delete() + .eq('user_id', user.id) + .gte('created_at', today.toISOString()); + + if (deleteError) { + console.error('Error deleting song of the day:', deleteError); + return NextResponse.json({ error: 'Failed to delete song of the day' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Song of the day removed', + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/spotify-search/route.js b/apps/web/app/api/spotify-search/route.js new file mode 100644 index 0000000..4f3e30d --- /dev/null +++ b/apps/web/app/api/spotify-search/route.js @@ -0,0 +1,82 @@ +// app/api/spotify-search/route.js +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; + +const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token'; +const SPOTIFY_SEARCH_URL = 'https://api.spotify.com/v1/search'; + +// Get app-only access token (client credentials flow) +async function getAppAccessToken() { + const clientId = process.env.SPOTIFY_CLIENT_ID; + const clientSecret = process.env.SPOTIFY_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + throw new Error('Spotify credentials not configured'); + } + + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + + const response = await fetch(SPOTIFY_TOKEN_URL, { + method: 'POST', + headers: { + 'Authorization': `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }); + + if (!response.ok) { + throw new Error('Failed to get Spotify access token'); + } + + const data = await response.json(); + return data.access_token; +} + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + + if (!query || query.trim().length === 0) { + return NextResponse.json({ error: 'Search query is required' }, { status: 400 }); + } + + // Get app access token (works for all users, regardless of login method) + const accessToken = await getAppAccessToken(); + + // Search Spotify + const searchUrl = `${SPOTIFY_SEARCH_URL}?q=${encodeURIComponent(query)}&type=track&limit=20`; + const searchResponse = await fetch(searchUrl, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }); + + if (!searchResponse.ok) { + console.error('Spotify search error:', await searchResponse.text()); + return NextResponse.json({ error: 'Failed to search Spotify' }, { status: 500 }); + } + + const data = await searchResponse.json(); + + return NextResponse.json({ + success: true, + tracks: data.tracks.items, + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/spotify/[...path]/route.js b/apps/web/app/api/spotify/[...path]/route.js index 6b0b626..061f7dd 100644 --- a/apps/web/app/api/spotify/[...path]/route.js +++ b/apps/web/app/api/spotify/[...path]/route.js @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; -import { getValidAccessToken } from '@/app/lib/spotify'; // make sure this path is correct +import { getValidAccessToken } from '@/lib/spotify'; const BASE = 'https://api.spotify.com'; @@ -13,7 +13,7 @@ async function makeSupabase() { return createRouteHandlerClient({ cookies: () => cookieStore }); } -async function handler(req, { params }) { +async function handler(req, context) { const sb = await makeSupabase(); // who is the user (from your own app session)? @@ -33,7 +33,8 @@ async function handler(req, { params }) { return new NextResponse(JSON.stringify({ error: 'token_error', message: 'An unexpected error occurred.' }), { status: 401 }); } - const path = params?.path?.join('/') ?? 'me'; + const params = await context.params; + const path = Array.isArray(params?.path) ? params.path.join('/') : 'me'; const target = `${BASE}/v1/${path}${req.nextUrl.search}`; const init = { diff --git a/apps/web/app/api/user/account/delete/route.js b/apps/web/app/api/user/account/delete/route.js new file mode 100644 index 0000000..f415bd4 --- /dev/null +++ b/apps/web/app/api/user/account/delete/route.js @@ -0,0 +1,162 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { + validateDeletionRequest, + verifyPassword, + deleteAccount, + checkAccountAge, +} from '@/lib/services/accountDeletion'; +import { + sanitizeRequestBody, + createErrorResponse, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * POST /api/user/account/delete + * Permanently delete user account and all associated data + * + * Request body: + * { + * password: string (required for email-based auth) + * confirmation_phrase: string (should be "DELETE MY ACCOUNT") + * reason?: string (optional feedback) + * } + * + * Returns: + * - 200: Account deletion successful + * - 400: Validation error (password incorrect, confirmation phrase incorrect, account too new) + * - 401: Unauthorized + * - 403: Account too new (less than 24 hours old) + * - 500: Server error + */ +export async function POST(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting (strict for account deletion) + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 5, // 5 deletion attempts per hour + windowMs: 60 * 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many deletion requests. Please try again in ${Math.ceil(resetSeconds / 60)} minutes.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '5', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and sanitize request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Sanitize input (preserve password field as it's needed for verification) + const sanitizedBody = sanitizeRequestBody(body, { + deep: true, + preserveUrls: false, + }); + + // Validate deletion request + const validation = validateDeletionRequest(user, sanitizedBody); + if (!validation.valid) { + const status = validation.hoursRemaining !== undefined ? 403 : 400; + return NextResponse.json( + { + error: validation.error, + message: validation.message, + hoursRemaining: validation.hoursRemaining, + }, + { status } + ); + } + + // Verify password (for email-based authentication) + const authProvider = user.app_metadata?.provider || 'email'; + + if (authProvider === 'email') { + const passwordValid = await verifyPassword(supabase, user, validation.data.password); + if (!passwordValid) { + return NextResponse.json( + { error: 'Invalid password' }, + { status: 400 } + ); + } + } else { + // For OAuth providers (Spotify, Google), password verification is not applicable + // The user is already authenticated via OAuth + console.log('[account deletion] OAuth user deletion:', authProvider); + } + + // Perform account deletion using service layer + const deletionResult = await deleteAccount(supabase, user, { + reason: validation.data.reason, + }); + + if (!deletionResult.success) { + return NextResponse.json( + { + error: 'Failed to delete account', + message: deletionResult.error || 'An error occurred during account deletion', + }, + { status: 500 } + ); + } + + // Return success response + return NextResponse.json({ + success: true, + message: 'Account deletion initiated successfully', + note: 'Your account and all associated data will be permanently deleted. You have been signed out.', + deleted: deletionResult.deleted, + }); + } catch (error) { + console.error('[account deletion] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', message: 'Failed to delete account' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/export/route.js b/apps/web/app/api/user/export/route.js new file mode 100644 index 0000000..a48a47e --- /dev/null +++ b/apps/web/app/api/user/export/route.js @@ -0,0 +1,281 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { + createErrorResponse, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/export + * Generate user data export (GDPR data portability) + * + * Returns: + * - 200: JSON file download with all user data + * - 401: Unauthorized + * - 429: Too many requests (rate limited - 1 export per 24 hours) + * - 500: Server error + */ +export async function GET(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = user.id; + + // Rate limiting: 1 export per 24 hours + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 1, // 1 export per 24 hours + windowMs: 24 * 60 * 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetHours = Math.ceil((rateLimit.resetAt - Date.now()) / (60 * 60 * 1000)); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Data export is limited to once per 24 hours. Please try again in ${resetHours} hours.`, + retryAfter: Math.ceil((rateLimit.resetAt - Date.now()) / 1000), + } + ), + { + status: 429, + headers: { + 'Retry-After': String(Math.ceil((rateLimit.resetAt - Date.now()) / 1000)), + 'X-RateLimit-Limit': '1', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Collect all user data + const exportData = { + export_metadata: { + exported_at: new Date().toISOString(), + user_id: userId, + format_version: '1.0', + }, + profile: {}, + preferences: {}, + listening_history: [], + playlists: [], + social_connections: [], + settings: {}, + }; + + try { + // 1. Profile Information + const { data: profile } = await supabase + .from('users') + .select('*') + .eq('id', userId) + .single(); + + if (profile) { + exportData.profile = { + id: profile.id, + display_name: profile.display_name, + bio: profile.bio, + username: profile.username, + profile_picture_url: profile.profile_picture_url, + created_at: profile.created_at, + updated_at: profile.updated_at, + }; + } + + // Add auth information + exportData.profile.auth = { + email: user.email, + email_verified: user.email_confirmed_at ? true : false, + provider: user.app_metadata?.provider || 'email', + created_at: user.created_at, + last_sign_in: user.last_sign_in_at, + }; + + // 2. Privacy Settings + const { data: privacySettings } = await supabase + .from('user_privacy_settings') + .select('*') + .eq('user_id', userId) + .single(); + + if (privacySettings) { + exportData.preferences.privacy = { + profile_visibility: privacySettings.profile_visibility, + playlist_visibility: privacySettings.playlist_visibility, + listening_activity_visible: privacySettings.listening_activity_visible, + song_of_day_visibility: privacySettings.song_of_day_visibility, + friend_request_setting: privacySettings.friend_request_setting, + searchable: privacySettings.searchable, + activity_feed_visible: privacySettings.activity_feed_visible, + created_at: privacySettings.created_at, + updated_at: privacySettings.updated_at, + }; + } + + // 3. Notification Preferences + const { data: notificationPreferences } = await supabase + .from('user_notification_preferences') + .select('*') + .eq('user_id', userId) + .single(); + + if (notificationPreferences) { + exportData.preferences.notifications = { + friend_requests_inapp: notificationPreferences.friend_requests_inapp, + friend_requests_email: notificationPreferences.friend_requests_email, + new_followers_inapp: notificationPreferences.new_followers_inapp, + new_followers_email: notificationPreferences.new_followers_email, + comments_inapp: notificationPreferences.comments_inapp, + comments_email: notificationPreferences.comments_email, + playlist_invites_inapp: notificationPreferences.playlist_invites_inapp, + playlist_invites_email: notificationPreferences.playlist_invites_email, + playlist_updates_inapp: notificationPreferences.playlist_updates_inapp, + playlist_updates_email: notificationPreferences.playlist_updates_email, + song_of_day_inapp: notificationPreferences.song_of_day_inapp, + song_of_day_email: notificationPreferences.song_of_day_email, + system_announcements_inapp: notificationPreferences.system_announcements_inapp, + system_announcements_email: notificationPreferences.system_announcements_email, + security_alerts_inapp: notificationPreferences.security_alerts_inapp, + security_alerts_email: notificationPreferences.security_alerts_email, + email_frequency: notificationPreferences.email_frequency, + notifications_enabled: notificationPreferences.notifications_enabled, + created_at: notificationPreferences.created_at, + updated_at: notificationPreferences.updated_at, + }; + } + + // 4. Listening History + // Fetch all listening history (may be large, but we want complete export) + const { data: history } = await supabase + .from('play_history') + .select('*') + .eq('user_id', userId) + .order('played_at', { ascending: false }); + + if (history) { + exportData.listening_history = history.map(item => ({ + track_id: item.track_id, + track_name: item.track_name, + artist_name: item.artist_name, + album_name: item.album_name, + played_at: item.played_at, + duration_ms: item.duration_ms, + spotify_uri: item.spotify_uri, + })); + } + + // 5. Playlists (if there's a playlists table) + // Note: Adjust table name and structure based on your schema + try { + const { data: playlists } = await supabase + .from('playlists') + .select('*') + .eq('user_id', userId) + .or('owner_id.eq.' + userId); + + if (playlists) { + exportData.playlists = playlists.map(playlist => ({ + id: playlist.id, + name: playlist.name, + description: playlist.description, + is_public: playlist.is_public, + created_at: playlist.created_at, + updated_at: playlist.updated_at, + // Note: Song list would need separate query if stored in separate table + })); + } + } catch (playlistError) { + // Table might not exist - that's okay + console.log('[data export] Playlists table not available:', playlistError.message); + } + + // 6. Social Connections (if there's a connections/friends table) + try { + const { data: connections } = await supabase + .from('friendships') + .select('*') + .or(`user_id.eq.${userId},friend_id.eq.${userId}`); + + if (connections) { + exportData.social_connections = connections.map(conn => ({ + friend_id: conn.friend_id === userId ? conn.user_id : conn.friend_id, + status: conn.status, + created_at: conn.created_at, + })); + } + } catch (connectionError) { + // Table might not exist - that's okay + console.log('[data export] Connections table not available:', connectionError.message); + } + + // 7. OAuth Connection Status + const { data: spotifyToken } = await supabase + .from('spotify_tokens') + .select('user_id, expires_at') + .eq('user_id', userId) + .single(); + + const { data: youtubeToken } = await supabase + .from('youtube_tokens') + .select('user_id, expires_at') + .eq('user_id', userId) + .single(); + + exportData.settings.oauth_connections = { + spotify_connected: !!spotifyToken, + spotify_token_expires_at: spotifyToken?.expires_at || null, + youtube_connected: !!youtubeToken, + youtube_token_expires_at: youtubeToken?.expires_at || null, + }; + + } catch (dataError) { + console.error('[data export] Error collecting data:', dataError); + // Continue with partial data + } + + // Generate filename with timestamp + const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const filename = `vybe-data-export-${timestamp}.json`; + + // Convert to JSON string with pretty formatting + const jsonString = JSON.stringify(exportData, null, 2); + + // Return as downloadable file + return new NextResponse(jsonString, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }, + }); + } catch (error) { + console.error('[data export] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', message: 'Failed to generate data export' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/notifications/route.js b/apps/web/app/api/user/notifications/route.js new file mode 100644 index 0000000..4547107 --- /dev/null +++ b/apps/web/app/api/user/notifications/route.js @@ -0,0 +1,324 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { notificationSchema, notificationPartialSchema, getDefaultNotificationPreferences } from '@/lib/schemas/notificationSchema'; +import { + validateRequest, + formatValidationErrors, + createErrorResponse, + logValidationFailure, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/notifications + * Fetch current user's notification preferences + * + * Returns: + * - 200: Notification preferences object + * - 401: Unauthorized + * - 500: Server error + */ +export async function GET() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Fetch notification preferences from user_notification_preferences table + const { data: notificationPreferences, error: notificationError } = await supabase + .from('user_notification_preferences') + .select('*') + .eq('user_id', user.id) + .single(); + + // Handle case where table doesn't exist yet or no record found + if (notificationError) { + // PGRST116 = no rows returned (table exists but no record) + // 42P01 = relation does not exist (table doesn't exist) + if (notificationError.code === 'PGRST116' || notificationError.code === '42P01') { + // Table doesn't exist yet or no record exists - return defaults + console.log('[notifications API] No notification preferences found, returning defaults'); + const defaults = getDefaultNotificationPreferences(); + return NextResponse.json(defaults); + } + + // Other errors - log but still return defaults to allow UI to work + console.error('[notifications API] Error fetching notification preferences:', notificationError); + const defaults = getDefaultNotificationPreferences(); + return NextResponse.json(defaults); + } + + // If no settings exist, return defaults + if (!notificationPreferences) { + const defaults = getDefaultNotificationPreferences(); + return NextResponse.json(defaults); + } + + // Return notification preferences in the expected format + return NextResponse.json({ + // Social Notifications + friend_requests_inapp: notificationPreferences.friend_requests_inapp ?? true, + friend_requests_email: notificationPreferences.friend_requests_email ?? true, + new_followers_inapp: notificationPreferences.new_followers_inapp ?? true, + new_followers_email: notificationPreferences.new_followers_email ?? false, + comments_inapp: notificationPreferences.comments_inapp ?? true, + comments_email: notificationPreferences.comments_email ?? false, + + // Playlist Notifications + playlist_invites_inapp: notificationPreferences.playlist_invites_inapp ?? true, + playlist_invites_email: notificationPreferences.playlist_invites_email ?? true, + playlist_updates_inapp: notificationPreferences.playlist_updates_inapp ?? true, + playlist_updates_email: notificationPreferences.playlist_updates_email ?? false, + + // System Notifications + song_of_day_inapp: notificationPreferences.song_of_day_inapp ?? true, + song_of_day_email: notificationPreferences.song_of_day_email ?? false, + system_announcements_inapp: notificationPreferences.system_announcements_inapp ?? true, + system_announcements_email: notificationPreferences.system_announcements_email ?? true, + security_alerts_inapp: notificationPreferences.security_alerts_inapp ?? true, // Always true + security_alerts_email: notificationPreferences.security_alerts_email ?? true, // Always true + + // Email Frequency + email_frequency: notificationPreferences.email_frequency || 'instant', + + // Master Toggle + notifications_enabled: notificationPreferences.notifications_enabled ?? true, + }); + } catch (error) { + console.error('[notifications API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/user/notifications + * Update user notification preferences + * + * Request body: + * { + * friend_requests_inapp?: boolean + * friend_requests_email?: boolean + * new_followers_inapp?: boolean + * new_followers_email?: boolean + * comments_inapp?: boolean + * comments_email?: boolean + * playlist_invites_inapp?: boolean + * playlist_invites_email?: boolean + * playlist_updates_inapp?: boolean + * playlist_updates_email?: boolean + * song_of_day_inapp?: boolean + * song_of_day_email?: boolean + * system_announcements_inapp?: boolean + * system_announcements_email?: boolean + * security_alerts_inapp?: boolean (always true, enforced) + * security_alerts_email?: boolean (always true, enforced) + * email_frequency?: 'instant' | 'daily' | 'weekly' + * notifications_enabled?: boolean + * } + * + * Returns: + * - 200: Updated notification preferences + * - 400: Validation error + * - 401: Unauthorized + * - 500: Server error + */ +export async function PUT(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 updates per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and validate request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Ensure security alerts are always enabled (enforce at API level) + body.security_alerts_inapp = true; + body.security_alerts_email = true; + + // Validate and sanitize input (use partial schema to allow partial updates) + const validationResult = validateRequest(body, notificationPartialSchema, { + endpoint: '/api/user/notifications', + userId: user.id, + sanitize: true, + logErrors: true, + }); + + if (!validationResult.success) { + return NextResponse.json( + validationResult.errors, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Check if notification preferences record exists + const { data: existingPreferences } = await supabase + .from('user_notification_preferences') + .select('id') + .eq('user_id', user.id) + .single(); + + // Prepare data for database update + const updateData = { + user_id: user.id, + ...validatedData, + // Ensure security alerts are always true + security_alerts_inapp: true, + security_alerts_email: true, + updated_at: new Date().toISOString(), + }; + + let result; + if (existingPreferences) { + // Update existing record + const { data: updatedPreferences, error: updateError } = await supabase + .from('user_notification_preferences') + .update(updateData) + .eq('user_id', user.id) + .select() + .single(); + + if (updateError) { + console.error('[notifications API] Error updating notification preferences:', updateError); + return NextResponse.json( + { error: 'Failed to update notification preferences' }, + { status: 500 } + ); + } + + result = updatedPreferences; + } else { + // Create new record + const { data: newPreferences, error: insertError } = await supabase + .from('user_notification_preferences') + .insert(updateData) + .select() + .single(); + + if (insertError) { + console.error('[notifications API] Error creating notification preferences:', insertError); + // Check if table doesn't exist + if (insertError.code === '42P01') { + return NextResponse.json( + { + error: 'Notification preferences table not available yet', + message: 'Please contact support or try again later', + }, + { status: 503 } + ); + } + return NextResponse.json( + { error: 'Failed to create notification preferences' }, + { status: 500 } + ); + } + + result = newPreferences; + } + + // Return updated notification preferences in the expected format + return NextResponse.json({ + // Social Notifications + friend_requests_inapp: result.friend_requests_inapp ?? true, + friend_requests_email: result.friend_requests_email ?? true, + new_followers_inapp: result.new_followers_inapp ?? true, + new_followers_email: result.new_followers_email ?? false, + comments_inapp: result.comments_inapp ?? true, + comments_email: result.comments_email ?? false, + + // Playlist Notifications + playlist_invites_inapp: result.playlist_invites_inapp ?? true, + playlist_invites_email: result.playlist_invites_email ?? true, + playlist_updates_inapp: result.playlist_updates_inapp ?? true, + playlist_updates_email: result.playlist_updates_email ?? false, + + // System Notifications + song_of_day_inapp: result.song_of_day_inapp ?? true, + song_of_day_email: result.song_of_day_email ?? false, + system_announcements_inapp: result.system_announcements_inapp ?? true, + system_announcements_email: result.system_announcements_email ?? true, + security_alerts_inapp: true, // Always true + security_alerts_email: true, // Always true + + // Email Frequency + email_frequency: result.email_frequency || 'instant', + + // Master Toggle + notifications_enabled: result.notifications_enabled ?? true, + + // Success message + message: 'Notification preferences updated successfully', + }); + } catch (error) { + console.error('[notifications API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/privacy/route.js b/apps/web/app/api/user/privacy/route.js new file mode 100644 index 0000000..eb4d0ff --- /dev/null +++ b/apps/web/app/api/user/privacy/route.js @@ -0,0 +1,320 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { privacySchema, privacyPartialSchema, getDefaultPrivacySettings } from '@/lib/schemas/privacySchema'; +import { + validateRequest, + formatValidationErrors, + createErrorResponse, + logValidationFailure, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/privacy + * Fetch current user's privacy settings + * + * Returns: + * - 200: Privacy settings object + * - 401: Unauthorized + * - 500: Server error + */ +export async function GET() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Fetch privacy settings from user_privacy_settings table + const { data: privacySettings, error: privacyError } = await supabase + .from('user_privacy_settings') + .select('*') + .eq('user_id', user.id) + .single(); + + // Handle case where table doesn't exist yet or no record found + if (privacyError) { + // PGRST116 = no rows returned (table exists but no record) + // 42P01 = relation does not exist (table doesn't exist) + // P0001 = other errors + if (privacyError.code === 'PGRST116' || privacyError.code === '42P01') { + // Table doesn't exist yet or no record exists - return defaults + console.log('[privacy API] No privacy settings found, returning defaults'); + const defaults = getDefaultPrivacySettings(); + return NextResponse.json(defaults); + } + + // Other errors - log but still return defaults to allow UI to work + console.error('[privacy API] Error fetching privacy settings:', privacyError); + const defaults = getDefaultPrivacySettings(); + return NextResponse.json(defaults); + } + + // If no settings exist, return defaults + if (!privacySettings) { + const defaults = getDefaultPrivacySettings(); + return NextResponse.json(defaults); + } + + // Return privacy settings in the expected format + return NextResponse.json({ + profile_visibility: privacySettings.profile_visibility || 'public', + playlist_visibility: privacySettings.playlist_visibility || 'public', + listening_activity_visible: privacySettings.listening_activity_visible ?? true, + song_of_day_visibility: privacySettings.song_of_day_visibility || 'public', + friend_request_setting: privacySettings.friend_request_setting || 'everyone', + searchable: privacySettings.searchable ?? true, + activity_feed_visible: privacySettings.activity_feed_visible ?? true, + }); + } catch (error) { + console.error('[privacy API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/user/privacy + * Update user privacy settings + * + * Request body: + * { + * profile_visibility?: 'public' | 'friends' | 'private' + * playlist_visibility?: 'public' | 'friends' | 'private' + * listening_activity_visible?: boolean + * song_of_day_visibility?: 'public' | 'friends' | 'private' + * friend_request_setting?: 'everyone' | 'friends_of_friends' | 'nobody' + * searchable?: boolean + * activity_feed_visible?: boolean + * } + * + * Returns: + * - 200: Updated privacy settings + * - 400: Validation error + * - 401: Unauthorized + * - 500: Server error + */ +export async function PUT(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 updates per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and validate request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Validate and sanitize input (use partial schema to allow partial updates) + const validationResult = validateRequest(body, privacyPartialSchema, { + endpoint: '/api/user/privacy', + userId: user.id, + sanitize: true, + logErrors: true, + }); + + if (!validationResult.success) { + return NextResponse.json( + validationResult.errors, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Check if privacy settings record exists + const { data: existingSettings } = await supabase + .from('user_privacy_settings') + .select('*') + .eq('user_id', user.id) + .single(); + + // Prepare update data + const updateData = {}; + + if (validatedData.profile_visibility !== undefined) { + updateData.profile_visibility = validatedData.profile_visibility; + } + if (validatedData.playlist_visibility !== undefined) { + updateData.playlist_visibility = validatedData.playlist_visibility; + } + if (validatedData.listening_activity_visible !== undefined) { + updateData.listening_activity_visible = validatedData.listening_activity_visible; + } + if (validatedData.song_of_day_visibility !== undefined) { + updateData.song_of_day_visibility = validatedData.song_of_day_visibility; + } + if (validatedData.friend_request_setting !== undefined) { + updateData.friend_request_setting = validatedData.friend_request_setting; + } + if (validatedData.searchable !== undefined) { + updateData.searchable = validatedData.searchable; + } + if (validatedData.activity_feed_visible !== undefined) { + updateData.activity_feed_visible = validatedData.activity_feed_visible; + } + + // Update timestamp + updateData.updated_at = new Date().toISOString(); + + let result; + if (existingSettings) { + // Update existing record + const { data: updatedSettings, error: updateError } = await supabase + .from('user_privacy_settings') + .update(updateData) + .eq('user_id', user.id) + .select() + .single(); + + if (updateError) { + console.error('[privacy API] Error updating privacy settings:', updateError); + return NextResponse.json( + { error: 'Failed to update privacy settings' }, + { status: 500 } + ); + } + + result = updatedSettings; + } else { + // Create new record with defaults merged with updates + const defaults = getDefaultPrivacySettings(); + const newSettings = { + user_id: user.id, + ...defaults, + ...updateData, + created_at: new Date().toISOString(), + }; + + const { data: createdSettings, error: createError } = await supabase + .from('user_privacy_settings') + .insert(newSettings) + .select() + .single(); + + if (createError) { + console.error('[privacy API] Error creating privacy settings:', createError); + return NextResponse.json( + { error: 'Failed to create privacy settings' }, + { status: 500 } + ); + } + + result = createdSettings; + } + + // Audit logging: Log privacy changes + try { + // Create audit log entry + const auditLog = { + user_id: user.id, + action: 'privacy_settings_updated', + details: { + changed_fields: Object.keys(updateData).filter(key => key !== 'updated_at'), + previous_values: existingSettings ? { + profile_visibility: existingSettings.profile_visibility, + playlist_visibility: existingSettings.playlist_visibility, + listening_activity_visible: existingSettings.listening_activity_visible, + song_of_day_visibility: existingSettings.song_of_day_visibility, + friend_request_setting: existingSettings.friend_request_setting, + searchable: existingSettings.searchable, + activity_feed_visible: existingSettings.activity_feed_visible, + } : null, + new_values: updateData, + }, + created_at: new Date().toISOString(), + }; + + // Try to insert audit log (if table exists) + // Note: This will fail silently if audit table doesn't exist yet + await supabase + .from('privacy_settings_audit_log') + .insert(auditLog) + .catch((error) => { + // Log but don't fail the request if audit logging fails + console.log('[privacy API] Audit logging not available:', error.message); + }); + } catch (auditError) { + // Don't fail the request if audit logging fails + console.log('[privacy API] Could not log privacy changes:', auditError.message); + } + + // Return updated privacy settings in the expected format + return NextResponse.json({ + profile_visibility: result.profile_visibility || 'public', + playlist_visibility: result.playlist_visibility || 'public', + listening_activity_visible: result.listening_activity_visible ?? true, + song_of_day_visibility: result.song_of_day_visibility || 'public', + friend_request_setting: result.friend_request_setting || 'everyone', + searchable: result.searchable ?? true, + activity_feed_visible: result.activity_feed_visible ?? true, + message: 'Privacy settings updated successfully', + }); + } catch (error) { + console.error('[privacy API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/profile/picture/route.js b/apps/web/app/api/user/profile/picture/route.js new file mode 100644 index 0000000..4f6ffed --- /dev/null +++ b/apps/web/app/api/user/profile/picture/route.js @@ -0,0 +1,265 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { + createErrorResponse, + checkRateLimit, + sanitizeString, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * POST /api/user/profile/picture + * Upload profile picture to Supabase Storage + * + * TODO FOR SUPABASE DEVELOPER: + * 1. Ensure the 'profile-pictures' bucket exists in Supabase Storage + * 2. Configure bucket policies to allow authenticated users to upload/read their own files + * 3. Files should be stored with path: {user_id}/profile-picture.{ext} + */ +export async function POST(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 20, // 20 uploads per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many upload requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '20', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Get file from form data + const formData = await request.formData(); + const file = formData.get('file'); + + if (!file || !(file instanceof File)) { + return NextResponse.json( + createErrorResponse('No file provided', 400), + { status: 400 } + ); + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + createErrorResponse('Invalid file type. Only JPEG, PNG, and WebP are allowed', 400), + { status: 400 } + ); + } + + // Validate file size (5MB max) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + return NextResponse.json( + createErrorResponse('File size exceeds 5MB limit', 400), + { status: 400 } + ); + } + + // Sanitize file name + const sanitizedName = sanitizeString(file.name); + + // Generate file path: {user_id}/profile-picture.{ext} + const fileExt = sanitizedName.split('.').pop() || 'jpg'; + // Ensure file extension is safe (only allow alphanumeric) + const safeExt = fileExt.replace(/[^a-zA-Z0-9]/g, ''); + const fileName = `${user.id}/profile-picture.${safeExt}`; + const filePath = `profile-pictures/${fileName}`; + + // Convert File to ArrayBuffer for Supabase Storage + const arrayBuffer = await file.arrayBuffer(); + const fileBuffer = Buffer.from(arrayBuffer); + + // Upload to Supabase Storage + // TODO FOR SUPABASE DEVELOPER: Ensure storage bucket 'profile-pictures' exists + const { data: uploadData, error: uploadError } = await supabase.storage + .from('profile-pictures') + .upload(fileName, fileBuffer, { + contentType: file.type, + upsert: true, // Replace existing file if it exists + }); + + if (uploadError) { + console.error('[profile picture API] Upload error:', uploadError); + return NextResponse.json( + { error: 'Failed to upload image. Please check Supabase Storage configuration.' }, + { status: 500 } + ); + } + + // Get public URL for the uploaded image + const { data: urlData } = supabase.storage + .from('profile-pictures') + .getPublicUrl(fileName); + + const publicUrl = urlData.publicUrl; + + // Update users table with profile picture URL + const { error: updateError } = await supabase + .from('users') + .update({ profile_picture_url: publicUrl }) + .eq('id', user.id); + + if (updateError) { + console.error('[profile picture API] Update error:', updateError); + // Even if update fails, return the URL - it can be updated later + return NextResponse.json({ + url: publicUrl, + message: 'Image uploaded but failed to update profile. URL returned.', + warning: true, + }); + } + + return NextResponse.json({ + url: publicUrl, + message: 'Profile picture uploaded successfully', + }); + } catch (error) { + console.error('[profile picture API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/user/profile/picture + * Remove profile picture from Supabase Storage + */ +export async function DELETE() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + createErrorResponse('Unauthorized', 401), + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 deletes per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many delete requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Get current profile to find existing picture URL + const { data: profile } = await supabase + .from('users') + .select('profile_picture_url') + .eq('id', user.id) + .single(); + + // Try to delete from storage if we have a URL + if (profile?.profile_picture_url) { + // Extract file path from URL + // Supabase Storage URLs are typically: https://{project}.supabase.co/storage/v1/object/public/{bucket}/{path} + const urlParts = profile.profile_picture_url.split('/'); + const fileNameIndex = urlParts.findIndex(part => part === 'profile-pictures') + 1; + + if (fileNameIndex > 0 && fileNameIndex < urlParts.length) { + const fileName = urlParts.slice(fileNameIndex).join('/'); + + const { error: deleteError } = await supabase.storage + .from('profile-pictures') + .remove([fileName]); + + if (deleteError) { + console.error('[profile picture API] Delete from storage error:', deleteError); + // Continue anyway - we'll still update the database + } + } + } + + // Update users table to remove profile picture URL + const { error: updateError } = await supabase + .from('users') + .update({ profile_picture_url: null }) + .eq('id', user.id); + + if (updateError) { + console.error('[profile picture API] Update error:', updateError); + return NextResponse.json( + { error: 'Failed to remove profile picture' }, + { status: 500 } + ); + } + + return NextResponse.json({ + message: 'Profile picture removed successfully', + }); + } catch (error) { + console.error('[profile picture API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/profile/route.js b/apps/web/app/api/user/profile/route.js new file mode 100644 index 0000000..8a1d3ca --- /dev/null +++ b/apps/web/app/api/user/profile/route.js @@ -0,0 +1,330 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { profileSchema } from '@/lib/schemas/profileSchema'; +import { + validateRequest, + formatValidationErrors, + createErrorResponse, + logValidationFailure, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/profile + * Fetch current user's profile information + */ +export async function GET() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Fetch user profile from users table + const { data: profile, error: profileError } = await supabase + .from('users') + .select('*') + .eq('id', user.id) + .single(); + + if (profileError && profileError.code !== 'PGRST116') { + console.error('[profile API] Error fetching profile:', profileError); + return NextResponse.json( + { error: 'Failed to fetch profile' }, + { status: 500 } + ); + } + + // Get authentication provider + const authProvider = user.app_metadata?.provider || 'email'; + + // Get provider account info from user metadata + const providerAccountName = user.user_metadata?.full_name || user.user_metadata?.name || null; + const providerAccountEmail = user.user_metadata?.email || user.email; + const providerUserId = user.user_metadata?.preferred_username || user.user_metadata?.user_name || null; + + // Check Spotify connection and get account info if available + const { data: spotifyToken } = await supabase + .from('spotify_tokens') + .select('user_id') + .eq('user_id', user.id) + .single(); + + let spotifyAccountInfo = null; + if (spotifyToken) { + // Try to fetch Spotify account info if token is valid + try { + const { getValidAccessToken } = await import('../../../lib/spotify.js'); + const accessToken = await getValidAccessToken(supabase, user.id); + const spotifyRes = await fetch('https://api.spotify.com/v1/me', { + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + if (spotifyRes.ok) { + const spotifyData = await spotifyRes.json(); + spotifyAccountInfo = { + display_name: spotifyData.display_name || null, + id: spotifyData.id || null, + }; + } + } catch (e) { + // Token may be invalid or expired - that's okay, we'll just show connected + console.log('[profile API] Could not fetch Spotify account:', e.message); + } + } + + // Check YouTube connection + const { data: youtubeToken } = await supabase + .from('youtube_tokens') + .select('user_id') + .eq('user_id', user.id) + .single(); + + let youtubeAccountInfo = null; + if (youtubeToken) { + // Try to fetch YouTube account info if token is available + // Note: YouTube API access would require similar token handling + // For now, we'll show connection status without account details + youtubeAccountInfo = { + connected: true, + }; + } + + // Format provider name for display + const formatProviderName = (provider) => { + const names = { + 'spotify': 'Spotify', + 'google': 'Google (YouTube)', + 'email': 'Email', + }; + return names[provider] || provider; + }; + + // Return profile data with connection status + return NextResponse.json({ + id: user.id, + email: user.email, + email_verified: user.email_confirmed_at ? true : false, + display_name: profile?.display_name || null, + bio: profile?.bio || null, + profile_picture_url: profile?.profile_picture_url || user.user_metadata?.avatar_url || null, + username: profile?.username || null, + created_at: user.created_at, + // Authentication provider info + auth_provider: authProvider, + auth_provider_display: formatProviderName(authProvider), + provider_account_name: providerAccountName, + provider_account_email: providerAccountEmail, + provider_user_id: providerUserId, + // Connection status + spotify_connected: !!spotifyToken, + spotify_account: spotifyAccountInfo, + youtube_connected: !!youtubeToken, + youtube_account: youtubeAccountInfo, + }); + } catch (error) { + console.error('[profile API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/user/profile + * Update user profile + * + * Request body: + * { + * display_name: string (required, 2-50 chars, alphanumeric + spaces) + * bio?: string (optional, max 200 chars) + * profile_picture_url?: string (optional, valid URL) + * } + * + * Returns: + * - 200: Updated profile data + * - 400: Validation error + * - 401: Unauthorized + * - 500: Server error + */ +export async function PUT(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 updates per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and validate request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Validate and sanitize input + const validationResult = validateRequest(body, profileSchema, { + endpoint: '/api/user/profile', + userId: user.id, + sanitize: true, + logErrors: true, + }); + + if (!validationResult.success) { + return NextResponse.json( + validationResult.errors, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Prepare update data (only include fields that are provided) + const updateData = {}; + + if (validatedData.display_name !== undefined) { + updateData.display_name = validatedData.display_name; + } + + if (validatedData.bio !== undefined) { + // Convert undefined to null for database (or empty string if preferred) + updateData.bio = validatedData.bio || null; + } + + if (validatedData.profile_picture_url !== undefined) { + // Convert null to null (or empty string if preferred) + updateData.profile_picture_url = validatedData.profile_picture_url || null; + } + + // Update user profile in database + const { data: updatedProfile, error: updateError } = await supabase + .from('users') + .update(updateData) + .eq('id', user.id) + .select() + .single(); + + if (updateError) { + console.error('[profile API] Error updating profile:', updateError); + + // Handle specific database errors + if (updateError.code === '23505') { // Unique constraint violation + return NextResponse.json( + { error: 'A profile with this information already exists' }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to update profile' }, + { status: 500 } + ); + } + + // Fetch updated profile with all fields (including those not updated) + const { data: profile, error: profileError } = await supabase + .from('users') + .select('*') + .eq('id', user.id) + .single(); + + if (profileError) { + console.error('[profile API] Error fetching updated profile:', profileError); + // Even if fetch fails, return what we updated + return NextResponse.json({ + id: user.id, + email: user.email, + display_name: updatedProfile?.display_name || null, + bio: updatedProfile?.bio || null, + profile_picture_url: updatedProfile?.profile_picture_url || null, + message: 'Profile updated successfully', + }); + } + + // Get authentication provider info for response + const authProvider = user.app_metadata?.provider || 'email'; + const formatProviderName = (provider) => { + const names = { + 'spotify': 'Spotify', + 'google': 'Google (YouTube)', + 'email': 'Email', + }; + return names[provider] || provider; + }; + + // Return updated profile data (matching GET endpoint format) + return NextResponse.json({ + id: user.id, + email: user.email, + email_verified: user.email_confirmed_at ? true : false, + display_name: profile?.display_name || null, + bio: profile?.bio || null, + profile_picture_url: profile?.profile_picture_url || user.user_metadata?.avatar_url || null, + username: profile?.username || null, + created_at: user.created_at, + auth_provider: authProvider, + auth_provider_display: formatProviderName(authProvider), + message: 'Profile updated successfully', + }); + } catch (error) { + console.error('[profile API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/users/search/route.js b/apps/web/app/api/users/search/route.js new file mode 100644 index 0000000..df4238f --- /dev/null +++ b/apps/web/app/api/users/search/route.js @@ -0,0 +1,124 @@ +// app/api/users/search/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + + // If no query, return all users (for browsing) + if (!query || query.trim().length === 0) { + const { data: allUsers, error: allUsersError } = await supabase + .from('users') + .select('id, username, display_name') + .neq('id', user.id) + .limit(50); + + if (allUsersError) { + console.error('Error fetching all users:', allUsersError); + return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 }); + } + + if (!allUsers || allUsers.length === 0) { + return NextResponse.json({ + success: true, + users: [] + }); + } + + // Get existing friendships + const { data: existingFriends } = await supabase + .from('friendships') + .select('user_id, friend_id, status') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`); + + const users = allUsers.map(u => { + const friendship = existingFriends?.find(f => + (f.user_id === user.id && f.friend_id === u.id) || + (f.user_id === u.id && f.friend_id === user.id) + ); + + return { + id: u.id, + email: u.email || '', + name: u.display_name || u.username || 'User', + username: u.username || '', + friendship_status: friendship?.status || null + }; + }); + + return NextResponse.json({ + success: true, + users + }); + } + + // Search the public users table + const searchQuery = query.toLowerCase(); + + console.log('Searching for:', searchQuery); + + const { data: matchingUsers, error: searchError } = await supabase + .from('users') + .select('id, username, display_name') + .or(`username.ilike.%${searchQuery}%,display_name.ilike.%${searchQuery}%`) + .neq('id', user.id) + .limit(20); + + if (searchError) { + console.error('Error searching users:', searchError); + return NextResponse.json({ error: 'Failed to search users' }, { status: 500 }); + } + + console.log('Found users:', matchingUsers); + + if (!matchingUsers || matchingUsers.length === 0) { + return NextResponse.json({ + success: true, + users: [] + }); + } + + // Get existing friendships for these users to show status + const userIds = matchingUsers.map(u => u.id); + const { data: existingFriends } = await supabase + .from('friendships') + .select('user_id, friend_id, status') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`); + + const users = matchingUsers.map(u => { + const friendship = existingFriends?.find(f => + (f.user_id === user.id && f.friend_id === u.id) || + (f.user_id === u.id && f.friend_id === user.id) + ); + + return { + id: u.id, + email: u.email || '', + name: u.display_name || u.username || 'User', + username: u.username || '', + friendship_status: friendship?.status || null + }; + }); + + return NextResponse.json({ + success: true, + users + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/youtube-search/route.js b/apps/web/app/api/youtube-search/route.js new file mode 100644 index 0000000..e7a010b --- /dev/null +++ b/apps/web/app/api/youtube-search/route.js @@ -0,0 +1,94 @@ +// app/api/youtube-search/route.js +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + + if (!query) { + return NextResponse.json({ error: 'Search query is required' }, { status: 400 }); + } + + // Check if YOUTUBE_API_KEY is configured + const apiKey = process.env.YOUTUBE_API_KEY; + + console.log('[youtube-search] Query:', query); + console.log('[youtube-search] API Key configured:', !!apiKey); + + if (!apiKey) { + console.error('[youtube-search] YOUTUBE_API_KEY not configured'); + // Return search URL as fallback + const searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; + return NextResponse.json({ + videoUrl: searchUrl, + fallback: true, + reason: 'No API key configured' + }); + } + + // Search YouTube for the video using API key + const apiUrl = `https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&maxResults=1&q=${encodeURIComponent(query)}&key=${apiKey}`; + console.log('[youtube-search] Calling YouTube API...'); + + const response = await fetch(apiUrl); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[youtube-search] API error:', response.status, errorText); + // Fallback to search URL + const searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; + return NextResponse.json({ + videoUrl: searchUrl, + fallback: true, + reason: `API error: ${response.status}` + }); + } + + const data = await response.json(); + console.log('[youtube-search] API response:', JSON.stringify(data, null, 2)); + + if (data.items && data.items.length > 0) { + const videoId = data.items[0].id.videoId; + const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; + + console.log('[youtube-search] Found video:', videoUrl); + + return NextResponse.json({ + videoUrl, + videoId, + title: data.items[0].snippet.title, + fallback: false + }); + } else { + console.log('[youtube-search] No results found'); + // No results found, return search URL + const searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; + return NextResponse.json({ + videoUrl: searchUrl, + fallback: true, + reason: 'No results found' + }); + } + + } catch (error) { + console.error('YouTube search error:', error); + const query = new URL(request.url).searchParams.get('q'); + const searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; + return NextResponse.json({ + videoUrl: searchUrl, + fallback: true + }); + } +} diff --git a/apps/web/app/api/youtube/[...path]/route.js b/apps/web/app/api/youtube/[...path]/route.js new file mode 100644 index 0000000..3a3fa15 --- /dev/null +++ b/apps/web/app/api/youtube/[...path]/route.js @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { getValidAccessToken } from '@/lib/youtube'; + +const BASE = 'https://www.googleapis.com'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +async function handler(req, context) { + const sb = await makeSupabase(); + + const { data: { user }, error: userErr } = await sb.auth.getUser(); + if (userErr || !user) { + return new NextResponse(JSON.stringify({ error: 'unauthorized' }), { status: 401 }); + } + + let accessToken; + try { + accessToken = await getValidAccessToken(sb, user.id); + } catch (e) { + console.error('[yt-proxy] token error:', e); + return new NextResponse(JSON.stringify({ error: 'token_error', message: 'An unexpected error occurred.' }), { status: 401 }); + } + + const params = await context.params; + const path = Array.isArray(params?.path) ? params.path.join('/') : 'youtube/v3/channels?part=id&mine=true'; + const target = `${BASE}/${path}${req.nextUrl.search}`; + + const init = { + method: req.method, + headers: { + Authorization: `Bearer ${accessToken}`, + 'content-type': req.headers.get('content-type') || undefined, + }, + }; + + if (req.method !== 'GET') { + init.body = await req.text(); + } + + const upstream = await fetch(target, init); + const text = await upstream.text(); + return new NextResponse(text, { + status: upstream.status, + headers: { 'content-type': upstream.headers.get('content-type') || 'application/json' }, + }); +} + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; + + + diff --git a/apps/web/app/auth/callback/route.js b/apps/web/app/auth/callback/route.js index f8fee68..f7c00e3 100644 --- a/apps/web/app/auth/callback/route.js +++ b/apps/web/app/auth/callback/route.js @@ -7,33 +7,153 @@ export async function GET(request) { const url = new URL(request.url); const code = url.searchParams.get('code'); const next = url.searchParams.get('next') || '/'; + const intendedProvider = url.searchParams.get('provider'); // The provider button that was clicked const cookieStore = await cookies(); const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); if (code) { - await supabase.auth.exchangeCodeForSession(code); + const { data: sessionData, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code); + + if (exchangeError) { + console.error('[callback] Error exchanging code for session:', exchangeError); + console.error('[callback] Error details:', JSON.stringify(exchangeError, null, 2)); + // Redirect to sign-in with error + const errorUrl = new URL('/sign-in', request.url); + errorUrl.searchParams.set('error', 'auth_failed'); + errorUrl.searchParams.set('message', exchangeError.message || 'Failed to authenticate'); + return NextResponse.redirect(errorUrl); + } + + // Use session from exchangeCodeForSession response first, then fallback to getSession + // This is important because cookies might not be set immediately after exchangeCodeForSession + const session = sessionData?.session || (await supabase.auth.getSession()).data?.session; + + if (!session || !session.user) { + console.error('[callback] No session found after exchangeCodeForSession'); + console.error('[callback] sessionData:', sessionData); + console.error('[callback] exchangeError was:', exchangeError); + const errorUrl = new URL('/sign-in', request.url); + errorUrl.searchParams.set('error', 'no_session'); + errorUrl.searchParams.set('message', 'Failed to create session. Please try again.'); + return NextResponse.redirect(errorUrl); + } + + console.log('[callback] session user:', session?.user?.id); + console.log('[callback] provider:', session?.user?.app_metadata?.provider); + console.log('[callback] exchangeCodeForSession has session:', !!sessionData?.session); + console.log('[callback] exchangeCodeForSession provider_token:', !!sessionData?.session?.provider_token); + + // Try to get tokens from exchangeCodeForSession response + const accessToken = sessionData?.session?.provider_token || session?.provider_token || null; + const refreshToken = sessionData?.session?.provider_refresh_token || session?.provider_refresh_token || null; + + console.log('[callback] has provider_token:', !!accessToken); + console.log('[callback] has provider_refresh_token:', !!refreshToken); - const { data: { session } } = await supabase.auth.getSession(); - console.log('[callback] session user:', session?.user?.id) if (session?.user) { const userId = session.user.id; - const accessToken = session.provider_token ?? null; - const refreshToken = session.provider_refresh_token ?? null; // must be non-null to “upgrade” - const expiresIn = session.provider_token_expires_in ?? 3600; - const scope = session.provider_scope ?? null; - - // overwrite the row with the latest token info - await supabase.from('spotify_tokens').upsert({ - user_id: userId, - access_token: accessToken, - refresh_token: refreshToken, // <-- NEEDS to be non-null to carry new scopes - expires_at: Math.floor(Date.now() / 1000) + expiresIn, - scope, - token_type: 'Bearer', - }, { onConflict: 'user_id' }); + + // Use the provider parameter from the URL (set by the login button that was clicked) + // This is the most reliable way to know which provider the user intended to use + const provider = intendedProvider || session.user?.app_metadata?.provider || null; + + const expiresIn = sessionData?.session?.provider_token_expires_in || session?.provider_token_expires_in || 3600; + const scope = sessionData?.session?.provider_scope || session?.provider_scope || null; + + console.log('[callback] Intended provider from button click:', intendedProvider); + console.log('[callback] Using provider:', provider); + + // Extract profile picture from OAuth user metadata + const avatarUrl = session.user?.user_metadata?.avatar_url || + session.user?.user_metadata?.picture || + null; + + // First check if user exists in our users table + const { data: existingUser, error: checkError } = await supabase + .from('users') + .select('id') + .eq('id', userId) + .maybeSingle(); + + console.log('[callback] User exists in users table:', !!existingUser, 'Error:', checkError); + + // Update user profile picture and last_used_provider + const updateData = { + last_used_provider: provider, + updated_at: new Date().toISOString() // Set updated_at manually to satisfy trigger + }; + if (avatarUrl) { + updateData.profile_picture_url = avatarUrl; + } + + console.log('[callback] Updating user', userId, 'with data:', updateData); + + // Use UPDATE instead of UPSERT to avoid username requirement + // The user should already exist from the auth trigger + const { data: upsertData, error: updateError } = await supabase + .from('users') + .update(updateData) + .eq('id', userId) + .select(); + + if (updateError) { + console.error('[callback] Error updating user:', updateError); + console.error('[callback] Full error details:', JSON.stringify(updateError, null, 2)); + } else { + console.log('[callback] Successfully updated last_used_provider to:', provider); + console.log('[callback] Upsert result:', upsertData); + } + + // Store OAuth tokens in appropriate table based on provider + if (provider === 'spotify') { + await supabase.from('spotify_tokens').upsert({ + user_id: userId, + access_token: accessToken, + refresh_token: refreshToken, + expires_at: Math.floor(Date.now() / 1000) + expiresIn, + scope, + token_type: 'Bearer', + }, { onConflict: 'user_id' }); + } else if (provider === 'google') { + console.log('[callback] Storing Google tokens:', { + userId, + hasAccessToken: !!accessToken, + hasRefreshToken: !!refreshToken, + expiresIn, + scope + }); + + const { error: tokenError } = await supabase.from('youtube_tokens').upsert({ + user_id: userId, + access_token: accessToken, + refresh_token: refreshToken, + expires_at: Math.floor(Date.now() / 1000) + expiresIn, + scope, + token_type: 'Bearer', + }, { onConflict: 'user_id' }); + + if (tokenError) { + console.error('[callback] Error storing YouTube tokens:', tokenError); + } else { + console.log('[callback] Successfully stored YouTube tokens'); + } + } + + // Add provider to redirect URL so the library knows which service to use + if (provider) { + const nextUrl = new URL(next, request.url); + + // Always add the 'from' parameter to track which provider they logged in with + nextUrl.searchParams.set('from', provider); + + console.log('[callback] Redirecting to:', nextUrl.toString()); + return NextResponse.redirect(nextUrl); + } } } + // Fallback redirect without provider parameter + console.log('[callback] No provider found, redirecting to:', next); return NextResponse.redirect(new URL(next, request.url)); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 8860907..bbbf32f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,8 +1,14 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #0a0a0a; + --foreground: #ededed; + + /* Dark mode scrollbar colors - track is transparent, only thumb visible */ + --scrollbar-track: transparent; + --scrollbar-thumb: rgba(255, 255, 255, 0.2); + --scrollbar-thumb-hover: rgba(255, 255, 255, 0.35); + --scrollbar-thumb-active: rgba(255, 255, 255, 0.45); } @theme inline { @@ -12,10 +18,16 @@ --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { +@media (prefers-color-scheme: light) { :root { - --background: #0a0a0a; - --foreground: #ededed; + --background: #ffffff; + --foreground: #171717; + + /* Light mode scrollbar colors */ + --scrollbar-track: transparent; + --scrollbar-thumb: rgba(0, 0, 0, 0.2); + --scrollbar-thumb-hover: rgba(0, 0, 0, 0.35); + --scrollbar-thumb-active: rgba(0, 0, 0, 0.45); } } @@ -25,6 +37,180 @@ 100% { background-position: 0% 50% } } +/* Animated gradient text (from wireframes chroma-text-gradient) */ +@keyframes chromaTextShift { + 0%, 100% { background-position: 0% 50% } + 25% { background-position: 100% 20% } + 50% { background-position: 80% 100% } + 75% { background-position: 20% 80% } +} + +/* Chroma background utilities and animations (from wireframes) */ +@keyframes chromaShift { + 0%, 100% { background-position: 0% 50% } + 20% { background-position: 80% 20% } + 40% { background-position: 20% 80% } + 60% { background-position: 100% 40% } + 80% { background-position: 40% 100% } +} + +@keyframes chromaFlow { + 0%, 100% { background-position: 0% 0% } + 16% { background-position: 70% 30% } + 33% { background-position: 30% 70% } + 50% { background-position: 100% 50% } + 66% { background-position: 50% 100% } + 83% { background-position: 20% 20% } +} + +@keyframes pulseGlow { + 0%, 100% { opacity: .4; transform: translate(-50%, -50%) scale(1) } + 50% { opacity: .7; transform: translate(-50%, -50%) scale(1.3) } +} + +@keyframes iconGlowPulse { + 0% { + opacity: 0.15; + background-color: rgba(139, 92, 246, 0.4); /* purple */ + } + 50% { + opacity: 0.2; + background-color: rgba(139, 92, 246, 0.35); /* purple fading */ + } + 100% { + opacity: 0.15; + background-color: rgba(45, 212, 191, 0.4); /* teal */ + } +} + +@layer utilities { + /* Typography scale utilities for consistency */ + .page-title { @apply text-2xl font-semibold text-white; } + .section-title { @apply text-xl font-semibold text-white; } + .section-subtitle { @apply text-sm text-gray-400; } + .chroma-bg { + position: relative; + overflow: hidden; + background: transparent !important; + } + .chroma-bg::before { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: -3; + background-size: 350% 350%; + animation: chromaShift 12s ease-in-out infinite; + opacity: 0.11; + background: + radial-gradient(circle at 15% 25%, #8b5cf6 0%, transparent 40%), + radial-gradient(circle at 85% 15%, #dc2626 0%, transparent 35%), + radial-gradient(circle at 25% 85%, #2dd4bf 0%, transparent 40%), + radial-gradient(circle at 75% 75%, #60a5fa 0%, transparent 30%), + radial-gradient(circle at 90% 50%, #ff8c42 0%, transparent 25%), + radial-gradient(circle at 10% 60%, #34d399 0%, transparent 30%), + radial-gradient(circle at 60% 10%, #67e8f9 0%, transparent 25%), + radial-gradient(circle at 50% 90%, #818cf8 0%, transparent 30%), + radial-gradient(circle at 30% 40%, #fbbf24 0%, transparent 20%), + radial-gradient(circle at 70% 20%, #22d3ee 0%, transparent 28%); + } + .chroma-bg::after { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: -2; + background-size: 400% 400%; + animation: chromaFlow 14s ease-in-out infinite; + background: + linear-gradient(45deg, #8b5cf6 0%, transparent 20%, #dc2626 40%, transparent 60%, #ff8c42 80%, transparent 100%), + linear-gradient(-45deg, transparent 0%, #2dd4bf 15%, transparent 30%, #fbbf24 50%, transparent 70%, #22d3ee 85%, transparent 100%), + linear-gradient(135deg, #60a5fa 0%, transparent 18%, #34d399 35%, transparent 55%, #818cf8 70%, transparent 90%), + linear-gradient(225deg, transparent 0%, #67e8f9 12%, transparent 25%, #dc2626 45%, transparent 65%), + linear-gradient(90deg, #ff8c42 0%, transparent 30%, #8b5cf6 60%, transparent 100%), + linear-gradient(180deg, transparent 0%, #fbbf24 25%, transparent 50%, #2dd4bf 75%, transparent 100%); + opacity: 0.08; + } + + /* Accent glow utility */ + .chroma-accent-glow { position: relative; } + .chroma-accent-glow::after { + content: ''; + position: absolute; + top: 50%; left: 50%; + width: 120px; height: 120px; + background: radial-gradient(circle, #8b5cf6 0%, transparent 70%); + transform: translate(-50%, -50%); + z-index: -1; opacity: .2; + animation: pulseGlow 3s ease-in-out infinite; + } + + /* Glass card variants (aligned with wireframes) */ + .glass-card { + background: rgba(17, 24, 39, 0.6); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + } + .glass-card:hover, + .glass-card:active { + background: rgba(31, 41, 55, 0.7); + border-color: rgba(255, 255, 255, 0.15); + } + + /* Nav hover polish */ + .nav-item:hover { + box-shadow: + 0 0 20px rgba(255, 255, 255, 0.10), + 0 0 40px rgba(255, 255, 255, 0.06), + 0 6px 24px rgba(0, 0, 0, 0.18) !important; + } +} + +@layer utilities { + .hover-glow:hover { + box-shadow: + 0 0 30px rgba(255, 255, 255, 0.10), + 0 0 60px rgba(255, 255, 255, 0.06), + 0 10px 32px rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.15) !important; + } +} + +@layer utilities { + .chroma-text-gradient { + background: linear-gradient(135deg, #8b5cf6, #dc2626, #ff8c42, #2dd4bf, #fbbf24, #60a5fa, #818cf8); + background-size: 500% 500%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: chromaTextShift 6s ease-in-out infinite; + } + + /* Vybe brand text gradient tuned for the chroma background */ + .vybe-logo-text { + background: linear-gradient(135deg, #a78bfa, #818cf8, #22d3ee, #34d399, #fbbf24); + background-size: 400% 400%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: chromaTextShift 8s ease-in-out infinite; + text-shadow: 0 1px 2px rgba(0,0,0,.35); + } + + /* Purple gradient background for auth pages (from wireframes) */ + .purple-gradient-bg { + background: linear-gradient(#000 0% 50%, #1a0a2e 85%, #2d1b4e 100%); + min-height: 100vh; + } + + /* Pulsing glow for icon */ + .icon-glow-pulse { + animation: iconGlowPulse 4s ease-in-out infinite; + } +} + body { background: #0f0f0f; @@ -154,5 +340,178 @@ main { 0%, 100% { background-position: 0% 50%, 0% 50%, 0% 50%, 0% 50%, 0% 0%; } 50% { background-position: 100% 40%, 20% 80%, 80% 20%, 0% 60%, 0% 100%; } } + + /* Glassmorphism card effect */ + .glass-card { + background: rgba(17, 24, 39, 0.6); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + } + + .glass-card:hover, + .glass-card:active { + background: rgba(31, 41, 55, 0.7); + border-color: rgba(255, 255, 255, 0.15); + } + + /* Glass select (rounded, matching dark glass background) */ + .glass-select { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.10); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 0.75rem; /* rounded-xl */ + transition: background-color .2s ease, border-color .2s ease; + appearance: none; + color: #fff; + caret-color: #fff; + outline: none; + box-shadow: none; + } + .glass-select:focus { + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.08); + } + .glass-select:hover { + background: rgba(255, 255, 255, 0.10); + border-color: rgba(255, 255, 255, 0.15); + } + + /* Best-effort dark dropdown items (browser support varies) */ + .glass-select option { + background-color: #0f172a; /* slate-900 */ + color: #fff; + } +} + +select { color-scheme: dark; } + +/* ==================== MODERN AUTO-HIDING SCROLLBARS ==================== */ + +/* Scrollbar styling for specific containers only - NOT for body/html */ + +/* Webkit scrollbar styling for containers */ +.overflow-y-auto::-webkit-scrollbar, +.overflow-x-auto::-webkit-scrollbar, +.overflow-auto::-webkit-scrollbar, +.modal-scroll::-webkit-scrollbar, +.glass-card::-webkit-scrollbar, +[role="dialog"] *::-webkit-scrollbar { + width: 10px; + height: 10px; + background: transparent; +} + +.overflow-y-auto::-webkit-scrollbar-track, +.overflow-x-auto::-webkit-scrollbar-track, +.overflow-auto::-webkit-scrollbar-track, +.modal-scroll::-webkit-scrollbar-track, +.glass-card::-webkit-scrollbar-track, +[role="dialog"] *::-webkit-scrollbar-track { + background: transparent; +} + +.overflow-y-auto::-webkit-scrollbar-thumb, +.overflow-x-auto::-webkit-scrollbar-thumb, +.overflow-auto::-webkit-scrollbar-thumb, +.modal-scroll::-webkit-scrollbar-thumb, +.glass-card::-webkit-scrollbar-thumb, +[role="dialog"] *::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 10px; + border: 2px solid transparent; + background-clip: padding-box; + transition: background-color 0.3s ease 0.5s; +} + +/* Show scrollbar on hover */ +.overflow-y-auto:hover::-webkit-scrollbar-thumb, +.overflow-x-auto:hover::-webkit-scrollbar-thumb, +.overflow-auto:hover::-webkit-scrollbar-thumb, +.modal-scroll:hover::-webkit-scrollbar-thumb, +.glass-card:hover::-webkit-scrollbar-thumb, +[role="dialog"]:hover *::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + transition: background-color 0.2s ease 0s; +} + +/* Show scrollbar when thumb itself is hovered */ +.overflow-y-auto::-webkit-scrollbar-thumb:hover, +.overflow-x-auto::-webkit-scrollbar-thumb:hover, +.overflow-auto::-webkit-scrollbar-thumb:hover, +.modal-scroll::-webkit-scrollbar-thumb:hover, +.glass-card::-webkit-scrollbar-thumb:hover, +[role="dialog"] *::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + transition: background-color 0s ease; +} + +/* Show scrollbar when actively scrolling */ +.overflow-y-auto::-webkit-scrollbar-thumb:active, +.overflow-x-auto::-webkit-scrollbar-thumb:active, +.overflow-auto::-webkit-scrollbar-thumb:active, +.modal-scroll::-webkit-scrollbar-thumb:active, +.glass-card::-webkit-scrollbar-thumb:active, +[role="dialog"] *::-webkit-scrollbar-thumb:active { + background: var(--scrollbar-thumb-active); + transition: background-color 0s ease; +} + +/* Horizontal scrollbars for containers */ +.overflow-x-auto::-webkit-scrollbar:horizontal, +.overflow-auto::-webkit-scrollbar:horizontal { + height: 10px; +} + +/* Scrollbar corner for containers */ +.overflow-y-auto::-webkit-scrollbar-corner, +.overflow-x-auto::-webkit-scrollbar-corner, +.overflow-auto::-webkit-scrollbar-corner { + background: transparent; +} + +html { + scroll-behavior: smooth; +} + +/* Custom scrollbar for overflow containers */ +.overflow-y-auto, +.overflow-x-auto, +.overflow-auto { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; +} + + +/* Mobile-friendly: thinner scrollbars on touch devices */ +@media (max-width: 768px) { + .overflow-y-auto::-webkit-scrollbar, + .overflow-x-auto::-webkit-scrollbar, + .overflow-auto::-webkit-scrollbar, + .modal-scroll::-webkit-scrollbar, + .glass-card::-webkit-scrollbar { + width: 8px; + height: 8px; + } } +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .overflow-y-auto::-webkit-scrollbar-thumb, + .overflow-x-auto::-webkit-scrollbar-thumb, + .overflow-auto::-webkit-scrollbar-thumb, + .modal-scroll::-webkit-scrollbar-thumb, + .glass-card::-webkit-scrollbar-thumb { + transition: none; + } + + html { + scroll-behavior: auto; + } +} + + diff --git a/apps/web/app/groups/[id]/page.jsx b/apps/web/app/groups/[id]/page.jsx new file mode 100644 index 0000000..b21e4b9 --- /dev/null +++ b/apps/web/app/groups/[id]/page.jsx @@ -0,0 +1,1000 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { supabaseBrowser } from '@/lib/supabase/client'; +import { useRouter } from 'next/navigation'; +import { Users, Heart, MoreVertical, Plus } from 'lucide-react'; +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; + +export default function GroupDetailPage({ params }) { + const supabase = supabaseBrowser(); + const router = useRouter(); + const [groupId, setGroupId] = useState(null); + const [user, setUser] = useState(null); + const [group, setGroup] = useState(null); + const [members, setMembers] = useState([]); + const [playlists, setPlaylists] = useState([]); + const [selectedPlaylist, setSelectedPlaylist] = useState('all'); + const [playlistSongs, setPlaylistSongs] = useState([]); + const [actualTrackCounts, setActualTrackCounts] = useState({}); + const [showAllSongs, setShowAllSongs] = useState(false); + const [loading, setLoading] = useState(true); + const [showAddPlaylistModal, setShowAddPlaylistModal] = useState(false); + const [currentlyPlaying, setCurrentlyPlaying] = useState(null); + + useEffect(() => { + // Unwrap params Promise + Promise.resolve(params).then((resolvedParams) => { + setGroupId(resolvedParams.id); + }); + }, [params]); + + useEffect(() => { + if (groupId) { + checkAuth(); + loadGroupData(); + } + }, [groupId]); + + useEffect(() => { + if (selectedPlaylist && playlists.length > 0) { + setShowAllSongs(false); // Reset when switching playlists + loadPlaylistSongs(selectedPlaylist); + } + }, [selectedPlaylist, playlists]); + + async function checkAuth() { + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error || !session) { + router.push('/sign-in'); + return; + } + + setUser(session.user); + } + + async function loadGroupData() { + setLoading(true); + + const { data: { session } } = await supabase.auth.getSession(); + if (!session || !groupId) return; + + // Get group details + const { data: groupData, error: groupError } = await supabase + .from('groups') + .select('*') + .eq('id', groupId) + .single(); + + if (groupError || !groupData) { + console.error('Error loading group:', groupError); + router.push('/groups'); + return; + } + + setGroup(groupData); + + // Get group members (owner + members) + const { data: memberData } = await supabase + .from('group_members') + .select('user_id, joined_at') + .eq('group_id', groupId); + + // Fetch owner user data - only select fields that exist in the users table + let ownerUser = null; + const { data: ownerUserData, error: ownerError } = await supabase + .from('users') + .select('id, username, profile_picture_url') + .eq('id', groupData.owner_id) + .maybeSingle(); + + if (ownerError) { + console.error('Error fetching owner user:', ownerError); + // Fallback: use session user data if it's the current user + if (session?.user?.id === groupData.owner_id) { + ownerUser = { + id: session.user.id, + username: session.user.email?.split('@')[0], + profile_picture_url: session.user.user_metadata?.avatar_url || null + }; + } + } else { + ownerUser = ownerUserData; + } + + // Fetch all member users data + const memberUserIds = (memberData || []).map(m => m.user_id); + let memberUsers = []; + + if (memberUserIds.length > 0) { + const { data: memberUsersData, error: memberUsersError } = await supabase + .from('users') + .select('id, username, profile_picture_url') + .in('id', memberUserIds); + + if (memberUsersError) { + console.error('Error fetching member users:', memberUsersError); + // Continue without member user data rather than failing + memberUsers = []; + } else { + memberUsers = memberUsersData || []; + } + } + + // Combine owner and members with full user data + // Always include the owner, even if user data is missing + const ownerMember = { + user_id: groupData.owner_id, + isOwner: true, + joined_at: groupData.created_at, + users: ownerUser || { + id: groupData.owner_id, + username: null, + email: null, + profile_picture_url: null + } + }; + + const otherMembers = (memberData || []) + .map(m => ({ + ...m, + isOwner: false, + users: memberUsers?.find(u => u.id === m.user_id) + })) + .filter(m => m.users); // Only filter out non-owner members without user data + + const allMembers = [ownerMember, ...otherMembers]; + + setMembers(allMembers); + + // Get playlists associated with this group + const { data: playlistData } = await supabase + .from('group_playlists') + .select('*') + .eq('group_id', groupId); + + setPlaylists(playlistData || []); + + // Get actual song counts for each playlist + if (playlistData && playlistData.length > 0) { + const counts = {}; + for (const playlist of playlistData) { + const { count } = await supabase + .from('playlist_songs') + .select('*', { count: 'exact', head: true }) + .eq('playlist_id', playlist.id); + counts[playlist.id] = count || 0; + } + setActualTrackCounts(counts); + } + + // Always start with "all" view to show merged playlists + setSelectedPlaylist('all'); + + setLoading(false); + } + + async function loadPlaylistSongs(playlistId) { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) return; + + let songs; + + if (playlistId === 'all') { + // Load all songs from all playlists in this group + const playlistIds = playlists.map(p => p.id); + + if (playlistIds.length === 0) { + setPlaylistSongs([]); + return; + } + + // Fetch songs in batches to bypass the 1000 row limit + let allSongs = []; + let rangeStart = 0; + const batchSize = 1000; + let hasMore = true; + + while (hasMore) { + const { data: batch, error: songsError } = await supabase + .from('playlist_songs') + .select(` + *, + song_likes ( + user_id + ), + group_playlists!inner ( + id, + name, + platform + ) + `) + .in('playlist_id', playlistIds) + .order('created_at', { ascending: true }) + .range(rangeStart, rangeStart + batchSize - 1); + + if (songsError) { + console.error('[Groups] Error loading songs:', songsError); + break; + } + + if (batch && batch.length > 0) { + allSongs = [...allSongs, ...batch]; + rangeStart += batchSize; + hasMore = batch.length === batchSize; // Continue if we got a full batch + } else { + hasMore = false; + } + } + + console.log('[Groups] Loaded songs count:', allSongs?.length); + songs = allSongs; + } else { + // Get songs from specific playlist - also use batching + let allPlaylistSongs = []; + let rangeStart = 0; + const batchSize = 1000; + let hasMore = true; + + while (hasMore) { + const { data: batch, error: playlistError } = await supabase + .from('playlist_songs') + .select(` + *, + song_likes ( + user_id + ), + group_playlists!inner ( + id, + name, + platform + ) + `) + .eq('playlist_id', playlistId) + .order('position', { ascending: true }) + .range(rangeStart, rangeStart + batchSize - 1); + + if (playlistError) { + console.error('[Groups] Error loading playlist songs:', playlistError); + break; + } + + if (batch && batch.length > 0) { + allPlaylistSongs = [...allPlaylistSongs, ...batch]; + rangeStart += batchSize; + hasMore = batch.length === batchSize; + } else { + hasMore = false; + } + } + + songs = allPlaylistSongs; + } + + // Transform songs to include liked status and playlist info + const songsWithLikes = (songs || []).map(song => ({ + ...song, + isLiked: song.song_likes?.some(like => like.user_id === session.user.id) || false, + likeCount: song.song_likes?.length || 0, + playlistName: song.group_playlists?.name || 'Unknown', + platform: song.group_playlists?.platform || 'unknown', + })); + + setPlaylistSongs(songsWithLikes); + } + + async function toggleLikeSong(songId, isCurrentlyLiked) { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) return; + + if (isCurrentlyLiked) { + // Unlike + await supabase + .from('song_likes') + .delete() + .eq('song_id', songId) + .eq('user_id', session.user.id); + } else { + // Like + await supabase + .from('song_likes') + .insert({ + song_id: songId, + user_id: session.user.id, + }); + } + + // Reload songs to update like status + loadPlaylistSongs(selectedPlaylist); + } + + if (loading) { + return ( +
+
+
+

Loading group...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+

{group?.name}

+

{group?.description || 'No description'}

+
+ +
+
+
+ + {/* Main Content */} +
+
+ {/* Playlist Songs */} +
+
+ {/* Playlist Selector */} + {playlists.length > 0 ? ( + <> +
+ +
+ +
+
+ + {/* Playlist Header */} +
+

+ {selectedPlaylist === 'all' ? 'All Playlists' : playlists.find(p => p.id === selectedPlaylist)?.name} +

+

+ {playlistSongs.length} tracks • {formatDuration(playlistSongs.reduce((acc, song) => acc + (song.duration || 0), 0))} +

+
+ + {/* Songs List */} +
+ {playlistSongs.length > 0 ? ( + (showAllSongs ? playlistSongs : playlistSongs.slice(0, 20)).map((song, index) => ( + + )) + ) : ( +
+

No songs in this playlist

+
+ )} +
+ + {playlistSongs.length > 20 && !showAllSongs && ( + + )} + + {showAllSongs && playlistSongs.length > 20 && ( + + )} + + ) : ( +
+ +

No playlists yet

+

Add a playlist from YouTube or Spotify to get started

+ +
+ )} +
+
+
+
+ + {/* Add Playlist Modal */} + {showAddPlaylistModal && groupId && ( + setShowAddPlaylistModal(false)} + onSuccess={() => { + setShowAddPlaylistModal(false); + loadGroupData(); + }} + /> + )} + + {/* Embedded Player */} + {currentlyPlaying && ( + setCurrentlyPlaying(null)} + /> + )} +
+ ); +} + +function SongItem({ song, index, onToggleLike, userId, onPlay, isPlaying }) { + const [isLiked, setIsLiked] = useState(song.isLiked || false); + + const handleLikeClick = (e) => { + e.stopPropagation(); // Prevent opening player when clicking like button + setIsLiked(!isLiked); + onToggleLike(song.id, isLiked); + }; + + const handleSongClick = () => { + onPlay(song); + }; + + return ( +
+ {/* Track Number */} + + {index + 1} + + + {/* Album Art */} +
+ {song.thumbnail_url ? ( + {song.title} + ) : ( +
+ {song.title?.charAt(0) || '?'} +
+ )} +
+ + {/* Song Info - Takes priority and available space */} +
+

{song.title || 'Untitled'}

+
+

{song.artist || 'Unknown Artist'}

+ {song.playlistName && ( + <> + + + {song.platform === 'youtube' ? 'YT' : song.platform === 'spotify' ? 'Spotify' : song.platform} + + + )} +
+
+ + {/* Like Button - Hidden on mobile, visible on desktop */} + + + {/* Duration - Hidden on mobile, visible on desktop */} + + {formatDuration(song.duration)} + +
+ ); +} + +function AddPlaylistModal({ groupId, onClose, onSuccess }) { + const supabase = supabaseBrowser(); + const [platform, setPlatform] = useState('spotify'); // 'youtube' or 'spotify' + const [selectedPlaylistId, setSelectedPlaylistId] = useState(''); + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState(false); + const [fetchingPlaylists, setFetchingPlaylists] = useState(false); + const [error, setError] = useState(''); + const [hasSpotify, setHasSpotify] = useState(false); + const [hasYoutube, setHasYoutube] = useState(false); + const [userExistingPlaylist, setUserExistingPlaylist] = useState(null); + + useEffect(() => { + checkConnectedAccounts(); + checkUserExistingPlaylist(); + }, []); + + useEffect(() => { + if (platform === 'spotify' && hasSpotify) { + fetchSpotifyPlaylists(); + } else if (platform === 'youtube' && hasYoutube) { + fetchYoutubePlaylists(); + } + }, [platform]); + + async function checkUserExistingPlaylist() { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) return; + + // Check if user already has a playlist in this group + const { data: existingPlaylist } = await supabase + .from('group_playlists') + .select('name, platform') + .eq('group_id', groupId) + .eq('added_by', session.user.id) + .maybeSingle(); + + setUserExistingPlaylist(existingPlaylist); + } + + async function checkConnectedAccounts() { + // Use getUser() instead of getSession() to get fresh user data with identities + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + console.log('[Groups] No user found'); + return; + } + + console.log('[Groups] User:', user.id); + console.log('[Groups] User identities:', user.identities); + + // Use same logic as LibraryView - check last_used_provider from database + const { data: userData } = await supabase + .from('users') + .select('last_used_provider') + .eq('id', user.id) + .maybeSingle(); + + const lastUsedProvider = userData?.last_used_provider; + + // Check identities as fallback + const identities = user.identities || []; + const hasGoogle = identities.some(id => id.provider === 'google'); + const hasSpotify = identities.some(id => id.provider === 'spotify'); + + console.log('[Groups] Last used provider from DB:', lastUsedProvider); + console.log('[Groups] Identities - Google:', hasGoogle, 'Spotify:', hasSpotify); + console.log('[Groups] Full identities array:', identities); + + // Determine provider (prioritize database value, then fall back to identities) + let provider = null; + + if (lastUsedProvider === 'google' || lastUsedProvider === 'spotify') { + provider = lastUsedProvider; + console.log('[Groups] Using provider from DB:', provider); + } else if (hasGoogle && !hasSpotify) { + provider = 'google'; + console.log('[Groups] Only Google linked'); + } else if (hasSpotify && !hasGoogle) { + provider = 'spotify'; + console.log('[Groups] Only Spotify linked'); + } else if (hasGoogle && hasSpotify) { + // Both linked - sort by most recent updated_at + const sortedIdentities = [...identities].sort((a, b) => { + return new Date(b.updated_at) - new Date(a.updated_at); + }); + provider = sortedIdentities[0]?.provider; + console.log('[Groups] Both linked, using most recent:', provider); + } + + console.log('[Groups] Final determined provider:', provider); + + if (provider === 'google') { + console.log('[Groups] Setting YouTube as available'); + setHasYoutube(true); + setHasSpotify(hasSpotify); + setPlatform('youtube'); + fetchYoutubePlaylists(); + } else if (provider === 'spotify') { + console.log('[Groups] Setting Spotify as available'); + setHasSpotify(true); + setHasYoutube(hasGoogle); + setPlatform('spotify'); + fetchSpotifyPlaylists(); + } else { + console.log('[Groups] No provider determined, using fallback'); + // Fallback: if we have identities but no provider was determined, + // still set the flags so the user can access their accounts + if (hasGoogle) { + console.log('[Groups] Fallback: Setting YouTube as available'); + setHasYoutube(true); + setPlatform('youtube'); + fetchYoutubePlaylists(); + } + if (hasSpotify) { + console.log('[Groups] Fallback: Setting Spotify as available'); + setHasSpotify(true); + if (!hasGoogle) { + setPlatform('spotify'); + fetchSpotifyPlaylists(); + } + } + } + } + + async function fetchSpotifyPlaylists() { + setFetchingPlaylists(true); + setError(''); + try { + const response = await fetch('/api/spotify/me/playlists?limit=50'); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch Spotify playlists'); + } + + setPlaylists(data.items || []); + } catch (err) { + console.error('Error fetching Spotify playlists:', err); + setError('Failed to load Spotify playlists. Please try reconnecting your account.'); + } finally { + setFetchingPlaylists(false); + } + } + + async function fetchYoutubePlaylists() { + setFetchingPlaylists(true); + setError(''); + try { + const response = await fetch('/api/youtube/youtube/v3/playlists?part=snippet&mine=true&maxResults=50'); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch YouTube playlists'); + } + + setPlaylists(data.items || []); + } catch (err) { + console.error('Error fetching YouTube playlists:', err); + setError('Failed to load YouTube playlists. Please try reconnecting your account.'); + } finally { + setFetchingPlaylists(false); + } + } + + async function handleAddPlaylist(e) { + e.preventDefault(); + setLoading(true); + setError(''); + + const { data: { session } } = await supabase.auth.getSession(); + if (!session) return; + + try { + const selectedPlaylist = playlists.find(p => + platform === 'spotify' ? p.id === selectedPlaylistId : p.id === selectedPlaylistId + ); + + if (!selectedPlaylist) { + throw new Error('Please select a playlist'); + } + + // Generate playlist URL based on platform + let playlistUrl; + if (platform === 'spotify') { + playlistUrl = selectedPlaylist.external_urls?.spotify || `https://open.spotify.com/playlist/${selectedPlaylist.id}`; + } else { + playlistUrl = `https://www.youtube.com/playlist?list=${selectedPlaylist.id}`; + } + + // Call API to import playlist + const response = await fetch('/api/import-playlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + groupId, + platform, + playlistUrl, + userId: session.user.id, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to import playlist'); + } + + onSuccess(); + } catch (err) { + console.error('Error importing playlist:', err); + setError(err.message || 'Failed to import playlist'); + setLoading(false); + } + } + + if (!hasSpotify && !hasYoutube) { + return ( +
+
+

No Accounts Connected

+

+ Please connect your Spotify or YouTube account in Settings to add playlists to this group. +

+
+ + +
+
+
+ ); + } + + return ( +
+
+

+ {userExistingPlaylist ? 'Replace Your Playlist' : 'Add Playlist'} +

+ + {userExistingPlaylist && ( +
+

⚠️ You already have a playlist in this group

+

+ Your current playlist "{userExistingPlaylist.name}" ({userExistingPlaylist.platform}) + will be removed and replaced with your new selection. +

+
+ )} + +
+
+ +
+ + +
+
+ +
+ + {fetchingPlaylists ? ( +
+ Loading playlists... +
+ ) : playlists.length > 0 ? ( +
+ {playlists.map((playlist) => { + const playlistName = platform === 'spotify' + ? playlist.name + : playlist.snippet?.title; + const playlistImage = platform === 'spotify' + ? playlist.images?.[0]?.url + : playlist.snippet?.thumbnails?.default?.url; + const trackCount = platform === 'spotify' + ? playlist.tracks?.total + : null; + + return ( + + ); + })} +
+ ) : ( +
+ No playlists found +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +} + +function EmbeddedPlayer({ song, onClose }) { + const getEmbedUrl = () => { + if (song.platform === 'youtube') { + return `https://www.youtube.com/embed/${song.external_id}?autoplay=1`; + } else if (song.platform === 'spotify') { + // Note: Spotify doesn't support autoplay in embeds due to browser policies + return `https://open.spotify.com/embed/track/${song.external_id}?utm_source=generator&theme=0`; + } + return null; + }; + + const embedUrl = getEmbedUrl(); + + if (!embedUrl) return null; + + const playerWidth = song.platform === 'youtube' ? 360 : 400; + const playerHeight = song.platform === 'youtube' ? 203 : 152; // 16:9 for YouTube + + return ( +
+
+
+ {song.title} +
+

{song.title}

+

{song.artist}

+
+ {song.platform === 'spotify' && ( + + Click ▶ + + )} +
+ +
+