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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 b6bc315dc53d6e99f0107f51e6c445ef9c3d1968 Mon Sep 17 00:00:00 2001 From: Ezzat Abdel-Khalek Date: Tue, 11 Nov 2025 18:10:35 -0500 Subject: [PATCH 12/43] feat: Simplify account settings UI (PBI-77) - Remove Privacy and Notifications tabs from settings navigation - Delete privacy and notifications page files - Improve mobile responsiveness with better padding and layouts - Enhance SettingsNav mobile menu (slides from right, better touch targets) - Add notification bell icon to Navbar (desktop and mobile) - Create NotificationBell and NotificationDropdown components - Create useNotifications hook with placeholder data - Optimize form inputs for mobile (better touch handling, responsive text) - Update conflict resolution handlers to remove privacy/notifications cases - Add old-pbi/ and pbi-77/ to .gitignore All settings now focus on essential features: Profile and Account only. --- .gitignore | 4 + apps/web/app/settings/account/page.jsx | 4 +- apps/web/app/settings/notifications/page.jsx | 418 ------------------- apps/web/app/settings/privacy/page.jsx | 412 ------------------ apps/web/app/settings/profile/page.jsx | 14 +- apps/web/components/Navbar.jsx | 61 +-- apps/web/components/NotificationBell.jsx | 104 +++++ apps/web/components/NotificationDropdown.jsx | 192 +++++++++ apps/web/components/SettingsNav.jsx | 25 +- apps/web/components/SettingsPageWrapper.jsx | 56 ++- apps/web/hooks/useNotifications.js | 91 ++++ 11 files changed, 473 insertions(+), 908 deletions(-) delete mode 100644 apps/web/app/settings/notifications/page.jsx delete mode 100644 apps/web/app/settings/privacy/page.jsx create mode 100644 apps/web/components/NotificationBell.jsx create mode 100644 apps/web/components/NotificationDropdown.jsx create mode 100644 apps/web/hooks/useNotifications.js diff --git a/.gitignore b/.gitignore index 6ef2e51..5372686 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,7 @@ coverage/ # Build outputs dist/ *.tsbuildinfo + +# Private planning and documentation folders +old-pbi/ +pbi-77/ diff --git a/apps/web/app/settings/account/page.jsx b/apps/web/app/settings/account/page.jsx index a2920d7..06244ed 100644 --- a/apps/web/app/settings/account/page.jsx +++ b/apps/web/app/settings/account/page.jsx @@ -118,7 +118,7 @@ function AccountSettingsContent() { return (
{/* Section Header */} -
+
@@ -131,7 +131,7 @@ function AccountSettingsContent() {
{/* Section Content */} -
+
{/* Account Information Section */}

Account Information

diff --git a/apps/web/app/settings/notifications/page.jsx b/apps/web/app/settings/notifications/page.jsx deleted file mode 100644 index 25609da..0000000 --- a/apps/web/app/settings/notifications/page.jsx +++ /dev/null @@ -1,418 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Bell, Users, Music, MessageSquare, Megaphone, Shield, Info, ToggleLeft, ToggleRight } from 'lucide-react'; -import SettingsPageWrapper, { useSettingsContext } from '@/components/SettingsPageWrapper'; -import { NotificationToggle } from '@/components/NotificationToggle'; -import { notificationSchema, getDefaultNotificationPreferences } from '@/lib/schemas/notificationSchema'; -import { useNotificationPreferences, useNotificationPreferencesUpdate } from '@/hooks/useNotificationPreferences'; - -// Inner component that uses the context (must be inside SettingsPageWrapper) -function NotificationSettingsContent() { - const { setHasUnsavedChanges, setFormSubmitHandler, setFormResetHandler } = useSettingsContext(); - - // Fetch notification preferences using TanStack Query - const { data: notificationData, isLoading: loading, error: notificationError } = useNotificationPreferences(); - const notificationUpdate = useNotificationPreferencesUpdate(); - - const { - register, - handleSubmit, - formState: { errors, isDirty }, - setValue, - watch, - reset, - } = useForm({ - resolver: zodResolver(notificationSchema), - defaultValues: getDefaultNotificationPreferences(), - }); - - // Watch all form values - const notificationsEnabled = watch('notifications_enabled'); - const emailFrequency = watch('email_frequency'); - - // Store original form values for cancel - const [originalValues, setOriginalValues] = useState(null); - - // Update unsaved changes indicator when form is dirty - useEffect(() => { - setHasUnsavedChanges(isDirty); - }, [isDirty, setHasUnsavedChanges]); - - // Initialize form with fetched notification preferences - useEffect(() => { - if (notificationData) { - // Set all form values from API response - Object.keys(notificationData).forEach(key => { - if (key !== 'message') { // Exclude success message from form data - setValue(key, notificationData[key], { shouldDirty: false }); - } - }); - - setOriginalValues(notificationData); - } else if (!loading && !notificationError) { - // If no data and not loading, use defaults - const defaultValues = getDefaultNotificationPreferences(); - Object.keys(defaultValues).forEach(key => { - setValue(key, defaultValues[key], { shouldDirty: false }); - }); - setOriginalValues(defaultValues); - } - }, [notificationData, loading, notificationError, setValue]); - - // Form submission handler - const onSubmit = async (data) => { - try { - // Ensure security alerts are always enabled - const submitData = { - ...data, - security_alerts_inapp: true, - security_alerts_email: true, - }; - - // Use TanStack Query mutation to update preferences - const updatedPreferences = await notificationUpdate.mutateAsync(submitData); - - // Reset form with updated data - reset(updatedPreferences); - setOriginalValues(updatedPreferences); - setHasUnsavedChanges(false); - } catch (error) { - console.error('Failed to update notification preferences:', error); - // Error toast is handled by the mutation hook - throw error; - } - }; - - // Register form submit handler with the wrapper - useEffect(() => { - const submitFn = () => { - return handleSubmit(onSubmit)(); - }; - setFormSubmitHandler(() => submitFn); - }, [handleSubmit, setFormSubmitHandler]); - - // Register form reset handler with the wrapper - useEffect(() => { - const resetFn = () => { - if (originalValues) { - reset(originalValues); - } - }; - setFormResetHandler(() => resetFn); - }, [reset, originalValues, setFormResetHandler]); - - // Master toggle handler - const handleMasterToggle = (enabled) => { - const allNotificationFields = [ - 'friend_requests_inapp', 'friend_requests_email', - 'new_followers_inapp', 'new_followers_email', - 'comments_inapp', 'comments_email', - 'playlist_invites_inapp', 'playlist_invites_email', - 'playlist_updates_inapp', 'playlist_updates_email', - 'song_of_day_inapp', 'song_of_day_email', - 'system_announcements_inapp', 'system_announcements_email', - ]; - - allNotificationFields.forEach(field => { - setValue(field, enabled, { shouldDirty: true }); - }); - - // Keep security alerts enabled - setValue('security_alerts_inapp', true, { shouldDirty: true }); - setValue('security_alerts_email', true, { shouldDirty: true }); - }; - - // Loading state - if (loading) { - return ( - <> -
-
- -
-

Notifications

-

- Configure your notification preferences -

-
-
-
-
-
-
-
-
- - ); - } - - // Error state - if (notificationError) { - return ( - <> -
-
- -
-

Notifications

-

- Configure your notification preferences -

-
-
-
-
-
-

Error loading notification preferences: {notificationError.message}

-
-
- - ); - } - - return ( -
- {/* Section Header */} -
-
- -
-

Notifications

-

- Configure your notification preferences -

-
-
-
- - {/* Section Content */} -
-
- {/* Master Toggle */} -
-
-
-

Enable All Notifications

-

- Master toggle to enable or disable all non-essential notifications at once -

-
- -
-
- - {/* Email Frequency */} -
- - -

- Choose how often you receive email notifications -

-
- - {/* Divider */} -
- - {/* Social Notifications */} -
-
- -

Social Notifications

-
- - setValue('friend_requests_inapp', value, { shouldDirty: true })} - onEmailChange={(value) => setValue('friend_requests_email', value, { shouldDirty: true })} - disabled={!notificationsEnabled} - /> - - setValue('new_followers_inapp', value, { shouldDirty: true })} - onEmailChange={(value) => setValue('new_followers_email', value, { shouldDirty: true })} - disabled={!notificationsEnabled} - /> - - setValue('comments_inapp', value, { shouldDirty: true })} - onEmailChange={(value) => setValue('comments_email', value, { shouldDirty: true })} - disabled={!notificationsEnabled} - /> -
- - {/* Divider */} -
- - {/* Playlist Notifications */} -
-
- -

Playlist Notifications

-
- - setValue('playlist_invites_inapp', value, { shouldDirty: true })} - onEmailChange={(value) => setValue('playlist_invites_email', value, { shouldDirty: true })} - disabled={!notificationsEnabled} - /> - - setValue('playlist_updates_inapp', value, { shouldDirty: true })} - onEmailChange={(value) => setValue('playlist_updates_email', value, { shouldDirty: true })} - disabled={!notificationsEnabled} - /> -
- - {/* Divider */} -
- - {/* System Notifications */} -
-
- -

System Notifications

-
- - setValue('song_of_day_inapp', value, { shouldDirty: true })} - onEmailChange={(value) => setValue('song_of_day_email', value, { shouldDirty: true })} - disabled={!notificationsEnabled} - /> - - setValue('system_announcements_inapp', value, { shouldDirty: true })} - onEmailChange={(value) => setValue('system_announcements_email', value, { shouldDirty: true })} - disabled={!notificationsEnabled} - /> - - setValue('security_alerts_inapp', true, { shouldDirty: true })} - onEmailChange={(value) => setValue('security_alerts_email', true, { shouldDirty: true })} - disabled={true} - required={true} - /> -
- - {/* Info Box */} -
-
- -
-

Notification Preferences Explained

-

- You can customize how and when you receive notifications. In-app notifications appear in the - Vybe app, while email notifications are sent to your registered email address. Security alerts - cannot be disabled to ensure account safety. -

-
-
-
-
- - {/* Hidden form fields for React Hook Form */} - - - - - - - - - - - - - - - - - -
-
- ); -} - -// Outer component that wraps content with SettingsPageWrapper -export default function NotificationSettingsPage() { - return ( - - - - ); -} diff --git a/apps/web/app/settings/privacy/page.jsx b/apps/web/app/settings/privacy/page.jsx deleted file mode 100644 index f923cfb..0000000 --- a/apps/web/app/settings/privacy/page.jsx +++ /dev/null @@ -1,412 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Shield, Info, Globe, Users, Lock, Eye, EyeOff, Search, Rss } from 'lucide-react'; -import SettingsPageWrapper, { useSettingsContext } from '@/components/SettingsPageWrapper'; -import { PrivacyToggle, PrivacyRadioGroup } from '@/components/PrivacyToggle'; -import { privacySchema, getDefaultPrivacySettings } from '@/lib/schemas/privacySchema'; -import { usePrivacySettings, usePrivacySettingsUpdate } from '@/hooks/usePrivacySettings'; - -// Inner component that uses the context (must be inside SettingsPageWrapper) -function PrivacySettingsContent() { - const { setHasUnsavedChanges, setFormSubmitHandler, setFormResetHandler } = useSettingsContext(); - - // Fetch privacy settings using TanStack Query - const { data: privacyData, isLoading: loading, error: privacyError } = usePrivacySettings(); - - // Privacy settings update mutation hook - const privacyUpdate = usePrivacySettingsUpdate(); - - const { - register, - handleSubmit, - formState: { errors, isDirty }, - setValue, - watch, - reset, - } = useForm({ - resolver: zodResolver(privacySchema), - defaultValues: getDefaultPrivacySettings(), - }); - - // Watch all form values - const profileVisibility = watch('profile_visibility'); - const playlistVisibility = watch('playlist_visibility'); - const listeningActivityVisible = watch('listening_activity_visible'); - const songOfDayVisibility = watch('song_of_day_visibility'); - const friendRequestSetting = watch('friend_request_setting'); - const searchable = watch('searchable'); - const activityFeedVisible = watch('activity_feed_visible'); - - // Store original form values for cancel - const [originalValues, setOriginalValues] = useState(null); - - // Update unsaved changes indicator when form is dirty - useEffect(() => { - setHasUnsavedChanges(isDirty); - }, [isDirty, setHasUnsavedChanges]); - - // Update form when privacy data loads - useEffect(() => { - if (privacyData) { - // Set form values - const formValues = { - profile_visibility: privacyData.profile_visibility || 'public', - playlist_visibility: privacyData.playlist_visibility || 'public', - listening_activity_visible: privacyData.listening_activity_visible ?? true, - song_of_day_visibility: privacyData.song_of_day_visibility || 'public', - friend_request_setting: privacyData.friend_request_setting || 'everyone', - searchable: privacyData.searchable ?? true, - activity_feed_visible: privacyData.activity_feed_visible ?? true, - }; - - setValue('profile_visibility', formValues.profile_visibility); - setValue('playlist_visibility', formValues.playlist_visibility); - setValue('listening_activity_visible', formValues.listening_activity_visible); - setValue('song_of_day_visibility', formValues.song_of_day_visibility); - setValue('friend_request_setting', formValues.friend_request_setting); - setValue('searchable', formValues.searchable); - setValue('activity_feed_visible', formValues.activity_feed_visible); - - // Store original values for cancel - setOriginalValues(formValues); - } - }, [privacyData, setValue]); - - // Form submission handler using the mutation hook - const onSubmit = async (data) => { - try { - // Use the mutation hook to update privacy settings - const updatedPrivacy = await privacyUpdate.mutateAsync(data); - - // Privacy data will be updated via cache invalidation - // Update form values with response data - const formValues = { - profile_visibility: updatedPrivacy.profile_visibility || 'public', - playlist_visibility: updatedPrivacy.playlist_visibility || 'public', - listening_activity_visible: updatedPrivacy.listening_activity_visible ?? true, - song_of_day_visibility: updatedPrivacy.song_of_day_visibility || 'public', - friend_request_setting: updatedPrivacy.friend_request_setting || 'everyone', - searchable: updatedPrivacy.searchable ?? true, - activity_feed_visible: updatedPrivacy.activity_feed_visible ?? true, - }; - - reset(formValues); - setOriginalValues(formValues); - setHasUnsavedChanges(false); - } catch (error) { - // Error is handled by the mutation hook (toast notification) - // Re-throw to allow form to handle error state if needed - throw error; - } - }; - - // Register form submit handler with the wrapper - useEffect(() => { - const submitFn = () => { - return handleSubmit(onSubmit)(); - }; - setFormSubmitHandler(() => submitFn); - }, [handleSubmit, setFormSubmitHandler]); - - // Register form reset handler with the wrapper - useEffect(() => { - const resetFn = () => { - if (originalValues) { - reset(originalValues); - } - }; - setFormResetHandler(() => resetFn); - }, [reset, originalValues, setFormResetHandler]); - - if (loading) { - return ( - <> -
-
- -
-

Privacy

-

- Control who can see your activity and playlists -

-
-
-
-
-
-
-
-
- - ); - } - - if (privacyError) { - return ( - <> -
-
- -
-

Privacy

-

- Control who can see your activity and playlists -

-
-
-
-
-
-

Error loading privacy settings: {privacyError.message}

-
-
- - ); - } - - return ( -
- {/* Section Header */} -
-
- -
-

Privacy

-

- Control who can see your activity and playlists -

-
-
-
- - {/* Section Content */} -
-
- {/* Profile Visibility */} -
- setValue('profile_visibility', value, { shouldDirty: true })} - requireConfirmation={true} - options={[ - { - value: 'public', - label: 'Public', - description: 'Anyone can view your profile', - icon: 'Globe', - }, - { - value: 'friends', - label: 'Friends Only', - description: 'Only your friends can view your profile', - icon: 'Users', - }, - { - value: 'private', - label: 'Private', - description: 'Only you can view your profile', - icon: 'Lock', - }, - ]} - /> -
- - {/* Divider */} -
- - {/* Playlist Visibility */} -
- setValue('playlist_visibility', value, { shouldDirty: true })} - requireConfirmation={true} - options={[ - { - value: 'public', - label: 'Public', - description: 'Anyone can view and follow your playlists', - icon: 'Globe', - }, - { - value: 'friends', - label: 'Friends Only', - description: 'Only your friends can view your playlists', - icon: 'Users', - }, - { - value: 'private', - label: 'Private', - description: 'Only you can view your playlists', - icon: 'Lock', - }, - ]} - /> -
- - {/* Divider */} -
- - {/* Listening Activity */} -
- setValue('listening_activity_visible', checked, { shouldDirty: true })} - requireConfirmation={true} - confirmationTitle="Hide Listening Activity?" - confirmationMessage="This will hide what you're currently listening to from your profile. Are you sure you want to continue?" - /> -
- - {/* Divider */} -
- - {/* Song of the Day Visibility */} -
- setValue('song_of_day_visibility', value, { shouldDirty: true })} - requireConfirmation={true} - options={[ - { - value: 'public', - label: 'Public', - description: 'Everyone can see your Song of the Day', - icon: 'Globe', - }, - { - value: 'friends', - label: 'Friends Only', - description: 'Only your friends can see your Song of the Day', - icon: 'Users', - }, - { - value: 'private', - label: 'Private', - description: 'Only you can see your Song of the Day', - icon: 'Lock', - }, - ]} - /> -
- - {/* Divider */} -
- - {/* Friend Request Settings */} -
- setValue('friend_request_setting', value, { shouldDirty: true })} - requireConfirmation={true} - options={[ - { - value: 'everyone', - label: 'Everyone', - description: 'Anyone can send you friend requests', - icon: 'Globe', - }, - { - value: 'friends_of_friends', - label: 'Friends of Friends', - description: 'Only people who are friends with your friends can send requests', - icon: 'Users', - }, - { - value: 'nobody', - label: 'Nobody', - description: 'No one can send you friend requests', - icon: 'Lock', - }, - ]} - /> -
- - {/* Divider */} -
- - {/* Search Visibility */} -
- setValue('searchable', checked, { shouldDirty: true })} - requireConfirmation={true} - confirmationTitle="Remove from Search Results?" - confirmationMessage="This will prevent others from finding you through search. Are you sure you want to continue?" - /> -
- - {/* Divider */} -
- - {/* Activity Feed Visibility */} -
- setValue('activity_feed_visible', checked, { shouldDirty: true })} - requireConfirmation={true} - confirmationTitle="Hide Activity Feed?" - confirmationMessage="This will hide your recent activity from your activity feed. Are you sure you want to continue?" - /> -
- - {/* Info Box */} -
-
- -
-

Privacy Settings Explained

-

- Your privacy settings control what information is visible to others. These settings help you - maintain control over your personal data and listening habits. You can change these settings - at any time, and changes take effect immediately. -

-
-
-
-
- - {/* Hidden form fields for React Hook Form */} - - - - - - - -
-
- ); -} - -// Outer component that wraps content with SettingsPageWrapper -export default function PrivacySettingsPage() { - return ( - - - - ); -} diff --git a/apps/web/app/settings/profile/page.jsx b/apps/web/app/settings/profile/page.jsx index 48045f2..54bbd29 100644 --- a/apps/web/app/settings/profile/page.jsx +++ b/apps/web/app/settings/profile/page.jsx @@ -166,7 +166,7 @@ function ProfileSettingsContent() { return (
{/* Section Header */} -
+
@@ -179,7 +179,7 @@ function ProfileSettingsContent() {
{/* Section Content */} -
+
{/* Display Name Input */}
@@ -192,9 +192,10 @@ function ProfileSettingsContent() { {...register('display_name')} maxLength={50} className={[ - 'w-full px-4 py-2 rounded-lg bg-white/5 border', - 'text-white placeholder-gray-500', + 'w-full px-4 py-2.5 rounded-lg bg-white/5 border', + 'text-white placeholder-gray-500 text-base sm:text-sm', 'focus:outline-none focus:ring-2 focus:ring-purple-500/50', + 'touch-manipulation', // Better mobile touch handling errors.display_name ? 'border-red-500/50' : 'border-white/20 focus:border-purple-500/50', @@ -226,9 +227,10 @@ function ProfileSettingsContent() { maxLength={200} rows={4} className={[ - 'w-full px-4 py-2 rounded-lg bg-white/5 border resize-none', - 'text-white placeholder-gray-500', + 'w-full px-4 py-2.5 rounded-lg bg-white/5 border resize-none', + 'text-white placeholder-gray-500 text-base sm:text-sm', 'focus:outline-none focus:ring-2 focus:ring-purple-500/50', + 'touch-manipulation', // Better mobile touch handling errors.bio ? 'border-red-500/50' : 'border-white/20 focus:border-purple-500/50', diff --git a/apps/web/components/Navbar.jsx b/apps/web/components/Navbar.jsx index c4b5462..0bb7895 100644 --- a/apps/web/components/Navbar.jsx +++ b/apps/web/components/Navbar.jsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { Home, Users, Music2, Library, User as UserIcon, LogOut, Settings, Menu, X } from 'lucide-react'; import { CONFIG } from '../config/constants.js'; import VybeLogo from './common/VybeLogo'; +import NotificationBell from './NotificationBell'; import { useState, useEffect, useRef } from 'react'; const links = CONFIG.NAV_LINKS.map(link => { @@ -142,34 +143,40 @@ export default function Navbar() { {/* spacer right of center */}
- {/* Sign out button - desktop only */} - + {/* Notification Bell and Sign out button - desktop only */} +
+ + +
- {/* Mobile hamburger menu button */} - + {/* Mobile: Notification Bell and hamburger menu button */} +
+ + +
{/* Mobile dropdown menu */} diff --git a/apps/web/components/NotificationBell.jsx b/apps/web/components/NotificationBell.jsx new file mode 100644 index 0000000..9259a50 --- /dev/null +++ b/apps/web/components/NotificationBell.jsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { Bell } from 'lucide-react'; +import NotificationDropdown from './NotificationDropdown'; +import { useNotifications } from '@/hooks/useNotifications'; + +/** + * NotificationBell - Bell icon component that shows notification count and dropdown + * + * Features: + * - Shows unread notification count badge + * - Opens dropdown on click + * - Positioned relative to button + */ +export default function NotificationBell() { + const [isOpen, setIsOpen] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState({}); + const buttonRef = useRef(null); + const { data, isLoading } = useNotifications(); + + const unreadCount = data?.unreadCount || 0; + + // Calculate dropdown position when opening + useEffect(() => { + if (isOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Position dropdown below the button, aligned to the right + // On mobile, adjust to fit viewport + const isMobile = viewportWidth < 768; + + setDropdownPosition({ + top: rect.bottom + 8, // 8px gap below button + right: isMobile ? 16 : Math.max(16, viewportWidth - rect.right - 16), // 16px from right edge + }); + } + }, [isOpen]); + + const handleToggle = () => { + setIsOpen(!isOpen); + }; + + const handleClose = () => { + setIsOpen(false); + }; + + return ( +
+ + + {/* Dropdown */} + {isOpen && ( + <> + {/* Backdrop for mobile */} + + ); +} + diff --git a/apps/web/components/NotificationDropdown.jsx b/apps/web/components/NotificationDropdown.jsx new file mode 100644 index 0000000..0d4251d --- /dev/null +++ b/apps/web/components/NotificationDropdown.jsx @@ -0,0 +1,192 @@ +'use client'; + +import { useRef, useEffect } from 'react'; +import { Bell, Check, X } from 'lucide-react'; +import { useNotifications, useMarkAllNotificationsRead } from '@/hooks/useNotifications'; + +/** + * NotificationDropdown - Dropdown panel that displays notifications + * + * @param {boolean} isOpen - Whether the dropdown is open + * @param {function} onClose - Function to call when closing the dropdown + * @param {object} position - Position object with { top, left, right } for dropdown placement + */ +export default function NotificationDropdown({ isOpen, onClose, position = {} }) { + const dropdownRef = useRef(null); + const { data, isLoading, error } = useNotifications(); + const markAllRead = useMarkAllNotificationsRead(); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (isOpen && dropdownRef.current && !dropdownRef.current.contains(event.target)) { + // Check if click was not on the bell icon itself (handled by parent) + const bellButton = event.target.closest('[data-notification-bell]'); + if (!bellButton) { + onClose(); + } + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + // Prevent body scroll when dropdown is open (optional) + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.body.style.overflow = 'unset'; + }; + }, [isOpen, onClose]); + + // Format timestamp to relative time + const formatTime = (date) => { + const now = new Date(); + const diff = now - date; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return date.toLocaleDateString(); + }; + + const handleMarkAllRead = async () => { + try { + await markAllRead.mutateAsync(); + } catch (error) { + console.error('Failed to mark all as read:', error); + } + }; + + if (!isOpen) return null; + + const notifications = data?.notifications || []; + const unreadCount = data?.unreadCount || 0; + + // Calculate dropdown position + const dropdownStyle = { + position: 'fixed', + ...(position.top && { top: `${position.top}px` }), + ...(position.right !== undefined && { right: `${position.right}px` }), + ...(position.left !== undefined && { left: `${position.left}px` }), + zIndex: 1000, + }; + + return ( +
+ {/* Header */} +
+
+ +

Notifications

+ {unreadCount > 0 && ( + + {unreadCount} new + + )} +
+
+ {unreadCount > 0 && ( + + )} + +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+
+
+ ) : error ? ( +
+

Failed to load notifications

+
+ ) : notifications.length === 0 ? ( +
+ +

No notifications

+

You're all caught up!

+
+ ) : ( +
+ {notifications.map((notification) => ( +
{ + // TODO: Handle notification click (navigate to relevant page) + console.log('Notification clicked:', notification); + }} + > +
+
+

+ {notification.title} +

+

+ {notification.message} +

+

+ {formatTime(notification.timestamp)} +

+
+ {!notification.read && ( +
+ )} +
+
+ ))} +
+ )} +
+ + {/* Footer (optional - could add "View all" link) */} + {notifications.length > 0 && ( +
+ +
+ )} +
+ ); +} + diff --git a/apps/web/components/SettingsNav.jsx b/apps/web/components/SettingsNav.jsx index 36b0c89..29d19b1 100644 --- a/apps/web/components/SettingsNav.jsx +++ b/apps/web/components/SettingsNav.jsx @@ -125,11 +125,12 @@ export default function SettingsNav({ } }} className={[ - 'w-full flex items-start gap-3 rounded-xl px-4 py-3 text-left transition-all block', - 'focus:outline-none', + 'w-full flex items-start gap-3 rounded-xl px-4 py-3.5 text-left transition-all block', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:ring-offset-2 focus:ring-offset-[#0f0f0f]', + 'touch-manipulation min-h-[48px]', // Better touch target for mobile isActive ? 'bg-gradient-to-r from-purple-500/20 to-blue-500/20 border border-purple-500/30 shadow-lg' - : 'text-gray-400 hover:bg-white/5 hover:text-white border border-transparent', + : 'text-gray-400 hover:bg-white/10 active:bg-white/15 hover:text-white border border-transparent', ].join(' ')} aria-current={isActive ? 'page' : undefined} aria-label={`${section.label} settings`} @@ -177,7 +178,7 @@ export default function SettingsNav({ {/* Hamburger Menu Button */} -
+
-

+

{hasUnsavedChanges ? 'Click to save your settings' : 'No changes to save'}

diff --git a/apps/web/hooks/useNotifications.js b/apps/web/hooks/useNotifications.js new file mode 100644 index 0000000..c912f82 --- /dev/null +++ b/apps/web/hooks/useNotifications.js @@ -0,0 +1,91 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +/** + * Hook to fetch and manage notifications + * Currently returns placeholder data - can be connected to real API later + */ +export function useNotifications() { + return useQuery({ + queryKey: ['notifications'], + queryFn: async () => { + // Placeholder: Return mock data for now + // TODO: Replace with actual API call when notification system is implemented + await new Promise(resolve => setTimeout(resolve, 300)); // Simulate API delay + + return { + unreadCount: 3, + notifications: [ + { + id: '1', + type: 'friend_request', + title: 'New Friend Request', + message: 'John Doe wants to be your friend', + timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago + read: false, + }, + { + id: '2', + type: 'playlist_invite', + title: 'Playlist Invitation', + message: 'Jane Smith invited you to collaborate on "Summer Vibes"', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago + read: false, + }, + { + id: '3', + type: 'song_of_day', + title: 'Song of the Day', + message: 'Your friend shared their song of the day', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 5), // 5 hours ago + read: false, + }, + ], + }; + }, + staleTime: 1000 * 60, // 1 minute + refetchOnWindowFocus: true, + }); +} + +/** + * Hook to mark notifications as read + */ +export function useMarkNotificationsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (notificationIds) => { + // Placeholder: Mark as read + // TODO: Replace with actual API call + await new Promise(resolve => setTimeout(resolve, 200)); + return { success: true }; + }, + onSuccess: () => { + // Invalidate and refetch notifications + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); +} + +/** + * Hook to mark all notifications as read + */ +export function useMarkAllNotificationsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + // Placeholder: Mark all as read + // TODO: Replace with actual API call + await new Promise(resolve => setTimeout(resolve, 200)); + return { success: true }; + }, + onSuccess: () => { + // Invalidate and refetch notifications + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); +} + From 7acb77120136c0372074c0e74f967efd1afc3f02 Mon Sep 17 00:00:00 2001 From: Ezzat Abdel-Khalek Date: Mon, 17 Nov 2025 14:06:46 -0500 Subject: [PATCH 13/43] feat(pbi-72): Add YouTube playlist export functionality (Steps 1-4) - Add 'Export to YouTube' button to group playlist view - Button only visible for users with YouTube/Google accounts - Works for both individual and 'All Playlists' views - Uses Youtube icon from lucide-react - Create /api/export/youtube POST endpoint - Authenticates user via Supabase session - Validates playlistId and groupId parameters - Fetches songs from playlist_songs table (ordered by position/created_at) - Creates YouTube playlist using YouTube Data API v3 - Returns playlist URL and metadata - Fix middleware to allow API routes to handle their own auth - API routes can now return proper JSON error responses (401, etc.) - Prevents unwanted redirects to sign-in page for API endpoints Note: Requires Supabase configuration: - Google OAuth scope: youtube.force-ssl - RLS policy fix for youtube_tokens table See pbi-72/SUPABASE-YOUTUBE-SETUP-REQUIRED.md for setup guide --- apps/web/app/api/export/youtube/route.js | 249 +++++++++++++++++++++++ apps/web/app/groups/[id]/page.jsx | 36 +++- apps/web/middleware.js | 3 + 3 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 apps/web/app/api/export/youtube/route.js diff --git a/apps/web/app/api/export/youtube/route.js b/apps/web/app/api/export/youtube/route.js new file mode 100644 index 0000000..b1d2717 --- /dev/null +++ b/apps/web/app/api/export/youtube/route.js @@ -0,0 +1,249 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { getValidAccessToken } from '@/lib/youtube'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * POST /api/export/youtube + * Export a playlist to YouTube + * + * Request Body: + * - playlistId: string (ID of the playlist to export, or 'all' for combined playlists) + * - groupId: string (ID of the group containing the playlist) + * + * Returns: + * - 200: Success with songs data + * - 400: Bad request (missing required fields) + * - 401: Unauthorized (user not logged in) + * - 404: Playlist not found + * - 500: Server error + */ +export async function POST(request) { + try { + // Step 1: Authenticate user + const supabase = await makeSupabase(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + console.error('[Export YouTube] Authentication failed:', authError); + return NextResponse.json( + { error: 'Unauthorized. Please log in.' }, + { status: 401 } + ); + } + + console.log('[Export YouTube] Authenticated user:', user.id); + + // Step 2: Parse and validate request body + const body = await request.json(); + const { playlistId, groupId } = body; + + if (!playlistId) { + return NextResponse.json( + { error: 'Missing required field: playlistId' }, + { status: 400 } + ); + } + + if (!groupId) { + return NextResponse.json( + { error: 'Missing required field: groupId' }, + { status: 400 } + ); + } + + console.log('[Export YouTube] Request:', { playlistId, groupId, userId: user.id }); + + // Step 3: Fetch playlist songs from database + let songs = []; + + if (playlistId === 'all') { + // Fetch all playlists in the group first + const { data: groupPlaylists, error: playlistsError } = await supabase + .from('group_playlists') + .select('id') + .eq('group_id', groupId); + + if (playlistsError) { + console.error('[Export YouTube] Error fetching group playlists:', playlistsError); + return NextResponse.json( + { error: 'Failed to fetch group playlists' }, + { status: 500 } + ); + } + + if (!groupPlaylists || groupPlaylists.length === 0) { + return NextResponse.json( + { error: 'No playlists found in this group' }, + { status: 404 } + ); + } + + const playlistIds = groupPlaylists.map(p => p.id); + + // Fetch all songs from all playlists in the group + const { data: allSongs, error: songsError } = await supabase + .from('playlist_songs') + .select('id, title, artist, duration, thumbnail_url, external_id, playlist_id, position, created_at') + .in('playlist_id', playlistIds) + .order('created_at', { ascending: true }); + + if (songsError) { + console.error('[Export YouTube] Error fetching songs:', songsError); + return NextResponse.json( + { + error: 'Failed to fetch playlist songs', + details: songsError.message, + code: songsError.code + }, + { status: 500 } + ); + } + + songs = allSongs || []; + } else { + // Fetch songs from a specific playlist + const { data: playlistSongs, error: songsError } = await supabase + .from('playlist_songs') + .select('id, title, artist, duration, thumbnail_url, external_id, playlist_id, position, created_at') + .eq('playlist_id', playlistId) + .order('position', { ascending: true }); + + if (songsError) { + console.error('[Export YouTube] Error fetching songs:', songsError); + return NextResponse.json( + { + error: 'Failed to fetch playlist songs', + details: songsError.message, + code: songsError.code + }, + { status: 500 } + ); + } + + songs = playlistSongs || []; + } + + console.log(`[Export YouTube] Found ${songs.length} songs`); + + // Step 4: Create YouTube playlist + // Determine playlist title + let playlistTitle; + if (playlistId === 'all') { + // For combined playlists, get the group name + const { data: groupData } = await supabase + .from('groups') + .select('name') + .eq('id', groupId) + .single(); + + playlistTitle = `[Vybe Export] ${groupData?.name || 'Group Playlist'}`; + } else { + // For individual playlists, get the playlist name + const { data: playlistData } = await supabase + .from('group_playlists') + .select('name') + .eq('id', playlistId) + .single(); + + playlistTitle = `[Vybe Export] ${playlistData?.name || 'Playlist'}`; + } + + console.log(`[Export YouTube] Creating YouTube playlist: "${playlistTitle}"`); + + // Get YouTube access token + console.log('[Export YouTube] Attempting to get YouTube access token for user:', user.id); + + let accessToken; + try { + accessToken = await getValidAccessToken(supabase, user.id); + console.log('[Export YouTube] Successfully got access token'); + } catch (error) { + console.error('[Export YouTube] Failed to get access token:', error); + console.error('[Export YouTube] Error details:', error.message, error.stack); + + // Check if user has YouTube tokens in database + const { data: tokenCheck, error: tokenError } = await supabase + .from('youtube_tokens') + .select('user_id, expires_at') + .eq('user_id', user.id) + .maybeSingle(); + + console.log('[Export YouTube] Token check result:', { tokenCheck, tokenError }); + + return NextResponse.json( + { + error: 'Failed to get YouTube access token', + details: error.message, + hint: tokenCheck ? 'Tokens exist but may be invalid' : 'No YouTube tokens found. Please connect your YouTube account in settings.' + }, + { status: 401 } + ); + } + + // Call YouTube API directly to create playlist + const createPlaylistResponse = await fetch('https://www.googleapis.com/youtube/v3/playlists?part=snippet,status', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + snippet: { + title: playlistTitle, + description: `Exported from Vybe on ${new Date().toLocaleDateString()}`, + }, + status: { + privacyStatus: 'private', // Create as private by default + }, + }), + }); + + if (!createPlaylistResponse.ok) { + const errorText = await createPlaylistResponse.text(); + console.error('[Export YouTube] Failed to create playlist:', errorText); + return NextResponse.json( + { + error: 'Failed to create YouTube playlist', + details: errorText + }, + { status: 500 } + ); + } + + const youtubePlaylist = await createPlaylistResponse.json(); + const youtubePlaylistId = youtubePlaylist.id; + + console.log(`[Export YouTube] Created YouTube playlist ID: ${youtubePlaylistId}`); + + // Step 5: Return success (songs will be added in next step) + return NextResponse.json( + { + message: 'YouTube playlist created successfully', + playlistId, + groupId, + songCount: songs.length, + youtubePlaylistId, + youtubePlaylistUrl: `https://www.youtube.com/playlist?list=${youtubePlaylistId}`, + playlistTitle, + note: 'Playlist is empty - songs will be added in Step 5' + }, + { status: 200 } + ); + + } catch (error) { + console.error('[Export YouTube] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/groups/[id]/page.jsx b/apps/web/app/groups/[id]/page.jsx index b21e4b9..11e0c32 100644 --- a/apps/web/app/groups/[id]/page.jsx +++ b/apps/web/app/groups/[id]/page.jsx @@ -3,7 +3,7 @@ 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 { Users, Heart, MoreVertical, Plus, Youtube } from 'lucide-react'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; export default function GroupDetailPage({ params }) { @@ -21,6 +21,7 @@ export default function GroupDetailPage({ params }) { const [loading, setLoading] = useState(true); const [showAddPlaylistModal, setShowAddPlaylistModal] = useState(false); const [currentlyPlaying, setCurrentlyPlaying] = useState(null); + const [hasYouTube, setHasYouTube] = useState(false); useEffect(() => { // Unwrap params Promise @@ -52,6 +53,13 @@ export default function GroupDetailPage({ params }) { } setUser(session.user); + + // Check if user has YouTube/Google connected + const { data: { user } } = await supabase.auth.getUser(); + if (user && user.identities) { + const hasGoogle = user.identities.some(id => id.provider === 'google'); + setHasYouTube(hasGoogle); + } } async function loadGroupData() { @@ -378,12 +386,26 @@ export default function GroupDetailPage({ params }) { {/* 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))} -

+
+
+

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

+

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

+
+ {/* Export to YouTube Button - Only shown for YouTube-connected users */} + {hasYouTube && ( + + )} +
{/* Songs List */} diff --git a/apps/web/middleware.js b/apps/web/middleware.js index f79c8ea..46ac24f 100644 --- a/apps/web/middleware.js +++ b/apps/web/middleware.js @@ -27,9 +27,12 @@ export async function middleware(req) { // If not authenticated: // - allow access to '/sign-in' + // - allow API routes to handle their own auth (return JSON errors) // - otherwise redirect to '/sign-in?next=...' if (!session) { if (pathname === '/sign-in') return res + // Let API routes handle their own authentication + if (pathname.startsWith('/api/')) return res const url = req.nextUrl.clone() url.pathname = CONFIG.AUTH_REDIRECT_PATH url.searchParams.set('next', pathname + req.nextUrl.search) From 4dceb9951c733403a6abfb2e846dffa213778758 Mon Sep 17 00:00:00 2001 From: Ezzat Abdel-Khalek Date: Tue, 18 Nov 2025 19:06:06 -0500 Subject: [PATCH 14/43] feat(pbi-72): Add youtube.force-ssl scope for playlist creation - Added youtube.force-ssl scope to Google OAuth sign-in - This enables playlist creation/management permissions - Users will see a new consent screen requesting YouTube permissions --- apps/web/app/(auth)/sign-in/page.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(auth)/sign-in/page.jsx b/apps/web/app/(auth)/sign-in/page.jsx index f63face..0a27e50 100644 --- a/apps/web/app/(auth)/sign-in/page.jsx +++ b/apps/web/app/(auth)/sign-in/page.jsx @@ -42,6 +42,7 @@ export default function SignInPage() { 'email', 'profile', 'https://www.googleapis.com/auth/youtube.readonly', + 'https://www.googleapis.com/auth/youtube.force-ssl', // Allow playlist creation ].join(' '), }, }); From 08e02f766bb1003f9b5901cb9354961582d9e322 Mon Sep 17 00:00:00 2001 From: Ezzat Abdel-Khalek Date: Wed, 19 Nov 2025 13:04:15 -0500 Subject: [PATCH 15/43] feat(pbi-72): Implement Step 5 - YouTube playlist export with search & add - Implemented search-and-add loop for songs using YouTube Search API - Added sequential processing with 100ms rate limiting between requests - Implemented robust error handling for search failures and quota limits - Added detailed result tracking (successful/failed songs with reasons) - Returns comprehensive response with song counts and error details - Gracefully handles quota exceeded errors (testing pending quota reset) Testing Notes: - Feature implementation complete and validated in logs - Hit YouTube API quota limit during testing (resets midnight PT) - Will validate full success with smaller playlist tomorrow Related Files: - apps/web/app/api/export/youtube/route.js - memory-bank/progress.md - memory-bank/architecture.md - memory-bank/code-review-notes.md --- apps/web/app/api/export/youtube/route.js | 124 +++++++++++++++++- apps/web/app/settings/layout.jsx | 1 + apps/web/components/DeleteAccountModal.jsx | 1 + apps/web/components/NotificationBell.jsx | 1 + apps/web/components/NotificationDropdown.jsx | 1 + apps/web/components/ProfilePictureUpload.jsx | 1 + apps/web/components/SaveStatusIndicator.jsx | 1 + .../web/components/SettingsConflictDialog.jsx | 1 + apps/web/components/SettingsSyncIndicator.jsx | 1 + apps/web/hooks/useAutoSave.js | 1 + apps/web/hooks/useNotifications.js | 1 + apps/web/hooks/useSettingsMigration.js | 1 + apps/web/hooks/useSettingsValidation.js | 1 + apps/web/lib/cache/settingsCache.js | 1 + .../create_notification_preferences_table.sql | 1 + .../create_privacy_settings_table.sql | 1 + apps/web/lib/migrations/settingsMigrations.js | 1 + apps/web/lib/privacy/enforcer.js | 1 + apps/web/lib/services/accountDeletion.js | 1 + .../web/lib/utils/settingsConflictResolver.js | 1 + apps/web/store/settingsStore.js | 1 + 21 files changed, 140 insertions(+), 4 deletions(-) diff --git a/apps/web/app/api/export/youtube/route.js b/apps/web/app/api/export/youtube/route.js index b1d2717..ba78724 100644 --- a/apps/web/app/api/export/youtube/route.js +++ b/apps/web/app/api/export/youtube/route.js @@ -223,17 +223,133 @@ export async function POST(request) { console.log(`[Export YouTube] Created YouTube playlist ID: ${youtubePlaylistId}`); - // Step 5: Return success (songs will be added in next step) + // Step 5: Search for songs and add them to the YouTube playlist + const addResults = { + successful: [], + failed: [], + skipped: [] + }; + + console.log(`[Export YouTube] Starting to add ${songs.length} songs to playlist`); + + for (let i = 0; i < songs.length; i++) { + const song = songs[i]; + console.log(`[Export YouTube] Processing song ${i + 1}/${songs.length}: "${song.title}" by "${song.artist}"`); + + try { + // Build search query: "artist - title" or just "title" if no artist + const searchQuery = song.artist + ? `${song.artist} - ${song.title}` + : song.title; + + console.log(`[Export YouTube] Searching for: "${searchQuery}"`); + + // Search YouTube for the song + const searchResponse = await fetch( + `https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q=${encodeURIComponent(searchQuery)}&maxResults=1`, + { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + } + ); + + if (!searchResponse.ok) { + const errorText = await searchResponse.text(); + console.error(`[Export YouTube] Search failed for "${searchQuery}":`, errorText); + addResults.failed.push({ + song: `${song.artist} - ${song.title}`, + reason: 'Search failed', + error: errorText + }); + continue; + } + + const searchData = await searchResponse.json(); + + // Check if we found any results + if (!searchData.items || searchData.items.length === 0) { + console.log(`[Export YouTube] No results found for "${searchQuery}"`); + addResults.failed.push({ + song: `${song.artist} - ${song.title}`, + reason: 'No YouTube results found' + }); + continue; + } + + const videoId = searchData.items[0].id.videoId; + console.log(`[Export YouTube] Found video ID: ${videoId}`); + + // Add the video to the playlist + const addVideoResponse = await fetch( + 'https://www.googleapis.com/youtube/v3/playlistItems?part=snippet', + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + snippet: { + playlistId: youtubePlaylistId, + resourceId: { + kind: 'youtube#video', + videoId: videoId, + }, + }, + }), + } + ); + + if (!addVideoResponse.ok) { + const errorText = await addVideoResponse.text(); + console.error(`[Export YouTube] Failed to add video ${videoId}:`, errorText); + addResults.failed.push({ + song: `${song.artist} - ${song.title}`, + videoId, + reason: 'Failed to add to playlist', + error: errorText + }); + continue; + } + + console.log(`[Export YouTube] Successfully added: "${song.title}"`); + addResults.successful.push({ + song: `${song.artist} - ${song.title}`, + videoId + }); + + // Small delay to avoid rate limits (100ms between requests) + await new Promise(resolve => setTimeout(resolve, 100)); + + } catch (error) { + console.error(`[Export YouTube] Error processing song "${song.title}":`, error); + addResults.failed.push({ + song: `${song.artist} - ${song.title}`, + reason: 'Unexpected error', + error: error.message + }); + } + } + + console.log(`[Export YouTube] Finished adding songs. Success: ${addResults.successful.length}, Failed: ${addResults.failed.length}`); + + // Step 6: Return success with detailed results return NextResponse.json( { - message: 'YouTube playlist created successfully', + message: 'YouTube playlist export completed', playlistId, groupId, - songCount: songs.length, youtubePlaylistId, youtubePlaylistUrl: `https://www.youtube.com/playlist?list=${youtubePlaylistId}`, playlistTitle, - note: 'Playlist is empty - songs will be added in Step 5' + totalSongs: songs.length, + songsAdded: addResults.successful.length, + songsFailed: addResults.failed.length, + results: { + successful: addResults.successful, + failed: addResults.failed + } }, { status: 200 } ); diff --git a/apps/web/app/settings/layout.jsx b/apps/web/app/settings/layout.jsx index 4aeccb4..96db7c5 100644 --- a/apps/web/app/settings/layout.jsx +++ b/apps/web/app/settings/layout.jsx @@ -10,3 +10,4 @@ export default function SettingsLayout({ children }) { + diff --git a/apps/web/components/DeleteAccountModal.jsx b/apps/web/components/DeleteAccountModal.jsx index 54efdf0..c3e789f 100644 --- a/apps/web/components/DeleteAccountModal.jsx +++ b/apps/web/components/DeleteAccountModal.jsx @@ -372,3 +372,4 @@ export default function DeleteAccountModal({ isOpen, onClose, onConfirm, isDelet ); } + diff --git a/apps/web/components/NotificationBell.jsx b/apps/web/components/NotificationBell.jsx index 9259a50..7deeffb 100644 --- a/apps/web/components/NotificationBell.jsx +++ b/apps/web/components/NotificationBell.jsx @@ -102,3 +102,4 @@ export default function NotificationBell() { ); } + diff --git a/apps/web/components/NotificationDropdown.jsx b/apps/web/components/NotificationDropdown.jsx index 0d4251d..9c352ba 100644 --- a/apps/web/components/NotificationDropdown.jsx +++ b/apps/web/components/NotificationDropdown.jsx @@ -190,3 +190,4 @@ export default function NotificationDropdown({ isOpen, onClose, position = {} }) ); } + diff --git a/apps/web/components/ProfilePictureUpload.jsx b/apps/web/components/ProfilePictureUpload.jsx index 476f664..9e688f8 100644 --- a/apps/web/components/ProfilePictureUpload.jsx +++ b/apps/web/components/ProfilePictureUpload.jsx @@ -293,3 +293,4 @@ export default function ProfilePictureUpload({ ); } + diff --git a/apps/web/components/SaveStatusIndicator.jsx b/apps/web/components/SaveStatusIndicator.jsx index 5d0592b..95601f9 100644 --- a/apps/web/components/SaveStatusIndicator.jsx +++ b/apps/web/components/SaveStatusIndicator.jsx @@ -52,3 +52,4 @@ export default function SaveStatusIndicator({ status, errorMessage, className = ); } + diff --git a/apps/web/components/SettingsConflictDialog.jsx b/apps/web/components/SettingsConflictDialog.jsx index a757619..e33bf3a 100644 --- a/apps/web/components/SettingsConflictDialog.jsx +++ b/apps/web/components/SettingsConflictDialog.jsx @@ -230,3 +230,4 @@ export default function SettingsConflictDialog({ ); } + diff --git a/apps/web/components/SettingsSyncIndicator.jsx b/apps/web/components/SettingsSyncIndicator.jsx index 8b971f8..9bf4baa 100644 --- a/apps/web/components/SettingsSyncIndicator.jsx +++ b/apps/web/components/SettingsSyncIndicator.jsx @@ -177,3 +177,4 @@ export default function SettingsSyncIndicator({ ); } + diff --git a/apps/web/hooks/useAutoSave.js b/apps/web/hooks/useAutoSave.js index b39b1fc..f1670c7 100644 --- a/apps/web/hooks/useAutoSave.js +++ b/apps/web/hooks/useAutoSave.js @@ -270,3 +270,4 @@ export function useAutoSave(options = {}) { }; } + diff --git a/apps/web/hooks/useNotifications.js b/apps/web/hooks/useNotifications.js index c912f82..2c5bb60 100644 --- a/apps/web/hooks/useNotifications.js +++ b/apps/web/hooks/useNotifications.js @@ -89,3 +89,4 @@ export function useMarkAllNotificationsRead() { }); } + diff --git a/apps/web/hooks/useSettingsMigration.js b/apps/web/hooks/useSettingsMigration.js index 5dc21e2..6e17a65 100644 --- a/apps/web/hooks/useSettingsMigration.js +++ b/apps/web/hooks/useSettingsMigration.js @@ -64,3 +64,4 @@ export function useSettingsMigration() { return null; } + diff --git a/apps/web/hooks/useSettingsValidation.js b/apps/web/hooks/useSettingsValidation.js index 03b74ce..5991b44 100644 --- a/apps/web/hooks/useSettingsValidation.js +++ b/apps/web/hooks/useSettingsValidation.js @@ -246,3 +246,4 @@ export function useSettingsValidation(type) { }; } + diff --git a/apps/web/lib/cache/settingsCache.js b/apps/web/lib/cache/settingsCache.js index 0b9edc1..a673e3b 100644 --- a/apps/web/lib/cache/settingsCache.js +++ b/apps/web/lib/cache/settingsCache.js @@ -280,3 +280,4 @@ export function clearSettingsCache(queryClient) { console.log('[settings cache] Cleared all settings cache'); } + diff --git a/apps/web/lib/migrations/create_notification_preferences_table.sql b/apps/web/lib/migrations/create_notification_preferences_table.sql index d50b92d..96c2a75 100644 --- a/apps/web/lib/migrations/create_notification_preferences_table.sql +++ b/apps/web/lib/migrations/create_notification_preferences_table.sql @@ -169,3 +169,4 @@ EXECUTE FUNCTION create_default_notification_preferences(); -- 4. Verify security alerts cannot be disabled -- ============================================ + diff --git a/apps/web/lib/migrations/create_privacy_settings_table.sql b/apps/web/lib/migrations/create_privacy_settings_table.sql index 9a256e8..466e98b 100644 --- a/apps/web/lib/migrations/create_privacy_settings_table.sql +++ b/apps/web/lib/migrations/create_privacy_settings_table.sql @@ -159,3 +159,4 @@ EXECUTE FUNCTION create_default_privacy_settings(); -- 3. Verify RLS policies work correctly -- ============================================ + diff --git a/apps/web/lib/migrations/settingsMigrations.js b/apps/web/lib/migrations/settingsMigrations.js index a40ead5..9f7f19d 100644 --- a/apps/web/lib/migrations/settingsMigrations.js +++ b/apps/web/lib/migrations/settingsMigrations.js @@ -418,3 +418,4 @@ export function createMockSettings(version = 0) { }; } + diff --git a/apps/web/lib/privacy/enforcer.js b/apps/web/lib/privacy/enforcer.js index 439e22a..65e33b2 100644 --- a/apps/web/lib/privacy/enforcer.js +++ b/apps/web/lib/privacy/enforcer.js @@ -467,3 +467,4 @@ export async function canSendFriendRequest(supabase, requesterId, targetUserId) return false; } + diff --git a/apps/web/lib/services/accountDeletion.js b/apps/web/lib/services/accountDeletion.js index 2ac37bc..e194c82 100644 --- a/apps/web/lib/services/accountDeletion.js +++ b/apps/web/lib/services/accountDeletion.js @@ -349,3 +349,4 @@ export function validateDeletionRequest(user, requestBody) { }; } + diff --git a/apps/web/lib/utils/settingsConflictResolver.js b/apps/web/lib/utils/settingsConflictResolver.js index 49833af..3b968c2 100644 --- a/apps/web/lib/utils/settingsConflictResolver.js +++ b/apps/web/lib/utils/settingsConflictResolver.js @@ -431,3 +431,4 @@ export function analyzeDataLoss(type, localData, remoteData, strategy) { }; } + diff --git a/apps/web/store/settingsStore.js b/apps/web/store/settingsStore.js index 3bdaa6e..a1dc5e5 100644 --- a/apps/web/store/settingsStore.js +++ b/apps/web/store/settingsStore.js @@ -455,3 +455,4 @@ const useSettingsStore = create( export default useSettingsStore; + From bd6bf43c7a58864d6300aa9019a588167c11ac0a Mon Sep 17 00:00:00 2001 From: HuTaoEMU Date: Wed, 19 Nov 2025 14:04:34 -0500 Subject: [PATCH 16/43] 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 ▶ + + )} +
+ +
+