From 07d82f79856d593a467623667ffc8f73cb0a8bf0 Mon Sep 17 00:00:00 2001 From: tGiech22 Date: Fri, 5 Dec 2025 01:18:41 -0500 Subject: [PATCH 01/12] feat: updated schema to include movies to be watched and movies completed and edited backend to account for that, also updated schema to allow for saving of displayname and bio --- backend/prisma/schema.prisma | 4 + backend/src/controllers/user.ts | 120 +++++++++++++----- backend/src/types/apiTypes.ts | 4 + backend/src/types/models.ts | 10 +- .../app/profilePage/components/MoviesGrid.tsx | 95 +++++++------- frontend/app/profilePage/index.tsx | 48 ++++--- frontend/app/profilePage/settings.tsx | 21 +-- frontend/services/userService.ts | 6 + 8 files changed, 193 insertions(+), 115 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 21212b6..d6d9f4e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -500,10 +500,14 @@ model UserProfile { profilePicture String? country String? city String? + displayName String? favoriteGenres String[] @default([]) favoriteMovies String[] @default([]) privateAccount Boolean @default(false) spoiler Boolean @default(false) + bio String? @db.Text + moviesToWatch String[] @default([]) + moviesCompleted String[] @default([]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt Comment Comment[] diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 32806e5..60eacac 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -16,11 +16,15 @@ export const updateUserProfile = async (req: AuthenticatedRequest, res: Response profilePicture, country, city, + displayName, favoriteGenres, favoriteMovies, updatedAt, privateAccount, spoiler, + bio, + moviesToWatch, + moviesCompleted, } = (req.body ?? {}) as Partial; try { @@ -43,20 +47,25 @@ export const updateUserProfile = async (req: AuthenticatedRequest, res: Response ) : undefined; - const data = mapUserProfilePatchToUpdateData({ - username, - onboardingCompleted, - primaryLanguage, - secondaryLanguage: mergedSecondaryLanguages, - profilePicture, - country, - city, - favoriteGenres, - favoriteMovies, - updatedAt, - privateAccount, - spoiler, - }); + const patch: Partial = {}; + if (username !== undefined) patch.username = username; + if (displayName !== undefined) patch.displayName = displayName; + if (onboardingCompleted !== undefined) patch.onboardingCompleted = onboardingCompleted; + if (primaryLanguage !== undefined) patch.primaryLanguage = primaryLanguage; + if (mergedSecondaryLanguages !== undefined) patch.secondaryLanguage = mergedSecondaryLanguages; + if (profilePicture !== undefined) patch.profilePicture = profilePicture; + if (country !== undefined) patch.country = country; + if (city !== undefined) patch.city = city; + if (favoriteGenres !== undefined) patch.favoriteGenres = favoriteGenres; + if (favoriteMovies !== undefined) patch.favoriteMovies = favoriteMovies; + if (bio !== undefined) patch.bio = bio; + if (moviesToWatch !== undefined) patch.moviesToWatch = moviesToWatch; + if (moviesCompleted !== undefined) patch.moviesCompleted = moviesCompleted; + if (privateAccount !== undefined) patch.privateAccount = privateAccount; + if (spoiler !== undefined) patch.spoiler = spoiler; + if (updatedAt !== undefined) patch.updatedAt = updatedAt; + + const data = mapUserProfilePatchToUpdateData(patch); const updated = await prisma.userProfile.update({ where: { userId: user.id }, @@ -107,11 +116,15 @@ export const ensureUserProfile = async (req: AuthenticatedRequest, res: Response favoriteGenres: [], secondaryLanguage: [], profilePicture: null, + displayName: null, country: null, city: null, primaryLanguage: 'English', privateAccount: false, spoiler: false, + bio: null, + moviesToWatch: [], + moviesCompleted: [], updatedAt: new Date(), }, }); @@ -166,12 +179,20 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = profilePicture: userProfile.profilePicture, country: userProfile.country, city: userProfile.city, + displayName: userProfile.displayName, favoriteGenres: Array.isArray(userProfile.favoriteGenres) ? userProfile.favoriteGenres as string[] : [], favoriteMovies: Array.isArray(userProfile.favoriteMovies) ? userProfile.favoriteMovies as string[] : [], + bio: userProfile.bio ?? null, + moviesToWatch: Array.isArray(userProfile.moviesToWatch) + ? (userProfile.moviesToWatch as string[]) + : [], + moviesCompleted: Array.isArray(userProfile.moviesCompleted) + ? (userProfile.moviesCompleted as string[]) + : [], privateAccount: Boolean(userProfile.privateAccount), spoiler: Boolean(userProfile.spoiler), createdAt: userProfile.createdAt, @@ -229,7 +250,6 @@ export const getUserRatings = async (req: Request, res: Response): Promise const ratings = await prisma.rating.findMany({ where: { userId: user_id }, orderBy: { date: "desc" }, - include: { Comment: true }, }); // Fetch user profile @@ -248,15 +268,23 @@ export const getUserRatings = async (req: Request, res: Response): Promise secondaryLanguage: Array.isArray(userProfile.secondaryLanguage) ? userProfile.secondaryLanguage as string[] : [], - profilePicture: userProfile.profilePicture, - country: userProfile.country, - city: userProfile.city, + profilePicture: userProfile.profilePicture, + country: userProfile.country, + city: userProfile.city, + displayName: userProfile.displayName, favoriteGenres: Array.isArray(userProfile.favoriteGenres) ? userProfile.favoriteGenres as string[] : [], favoriteMovies: Array.isArray(userProfile.favoriteMovies) ? userProfile.favoriteMovies as string[] : [], + bio: userProfile.bio ?? null, + moviesToWatch: Array.isArray(userProfile.moviesToWatch) + ? (userProfile.moviesToWatch as string[]) + : [], + moviesCompleted: Array.isArray(userProfile.moviesCompleted) + ? (userProfile.moviesCompleted as string[]) + : [], privateAccount: Boolean(userProfile.privateAccount), spoiler: Boolean(userProfile.spoiler), createdAt: userProfile.createdAt, @@ -306,20 +334,27 @@ export const getUserComments = async (req: Request, res: Response): Promise> ): Prisma.UserProfileUpdateInput { const data: Prisma.UserProfileUpdateInput = {}; @@ -382,6 +425,9 @@ export function mapUserProfilePatchToUpdateData( if (Object.prototype.hasOwnProperty.call(patch, "username")) { data.username = patch.username ?? null; } + if (Object.prototype.hasOwnProperty.call(patch, "displayName")) { + data.displayName = patch.displayName ?? null; + } if (Object.prototype.hasOwnProperty.call(patch, "onboardingCompleted")) { data.onboardingCompleted = patch.onboardingCompleted; } @@ -406,11 +452,25 @@ export function mapUserProfilePatchToUpdateData( if (Object.prototype.hasOwnProperty.call(patch, "favoriteMovies")) { data.favoriteMovies = patch.favoriteMovies ?? []; } + if (Object.prototype.hasOwnProperty.call(patch, "bio")) { + data.bio = patch.bio ?? null; + } + if (Object.prototype.hasOwnProperty.call(patch, "moviesToWatch")) { + data.moviesToWatch = patch.moviesToWatch ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "moviesCompleted")) { + data.moviesCompleted = patch.moviesCompleted ?? []; + } if (Object.prototype.hasOwnProperty.call(patch, "privateAccount")) { data.privateAccount = patch.privateAccount ?? false; } - if (Object.prototype.hasOwnProperty.call(patch, "spoiler")) { - data.spoiler = patch.spoiler ?? false; + const spoilerValue = Object.prototype.hasOwnProperty.call(patch, "spoiler") + ? patch.spoiler + : Object.prototype.hasOwnProperty.call(patch as any, "spoilers") + ? (patch as any).spoilers + : undefined; + if (spoilerValue !== undefined) { + data.spoiler = spoilerValue ?? false; } // Always refresh updatedAt to now unless caller explicitly provided one diff --git a/backend/src/types/apiTypes.ts b/backend/src/types/apiTypes.ts index 5070631..347b251 100644 --- a/backend/src/types/apiTypes.ts +++ b/backend/src/types/apiTypes.ts @@ -51,10 +51,14 @@ export type UpdateUserProfileInput = { secondaryLanguage?: string[]; country?: string; city?: string; + displayName?: string | null; favoriteGenres?: string[]; favoriteMovies?: string[]; + bio?: string | null; privateAccount?: boolean; spoiler?: boolean; + moviesToWatch?: string[]; + moviesCompleted?: string[]; }; export type UpdateUserProfileResponse = { message: string; data: UserProfile }; diff --git a/backend/src/types/models.ts b/backend/src/types/models.ts index 118948d..816489d 100644 --- a/backend/src/types/models.ts +++ b/backend/src/types/models.ts @@ -26,8 +26,12 @@ export type UserProfile = { profilePicture: string | null; country: string | null; city: string | null; + displayName?: string | null; favoriteGenres: string[]; favoriteMovies: string[]; + bio?: string | null; + moviesToWatch: string[]; + moviesCompleted: string[]; privateAccount: boolean; spoiler: boolean; createdAt: Date; @@ -43,11 +47,6 @@ export type Rating = { tags: string[]; date: string; votes: number; - UserProfile?: { - userId: string; - username: string | null; - }; - threadedComments?: unknown[]; }; export type Comment = { @@ -231,4 +230,3 @@ export type ChunkSummary = { stats: SentimentStats; quotes: string[]; }; - diff --git a/frontend/app/profilePage/components/MoviesGrid.tsx b/frontend/app/profilePage/components/MoviesGrid.tsx index f76f48c..01a9218 100644 --- a/frontend/app/profilePage/components/MoviesGrid.tsx +++ b/frontend/app/profilePage/components/MoviesGrid.tsx @@ -20,9 +20,11 @@ type MovieListItem = { type Props = { userId?: string | null; + moviesToWatch?: string[] | null; + moviesCompleted?: string[] | null; }; -const MoviesGrid = ({ userId }: Props) => { +const MoviesGrid = ({ userId, moviesToWatch, moviesCompleted }: Props) => { const [activeSubTab, setActiveSubTab] = useState<'toWatch' | 'completed'>('completed'); const [moviesByStatus, setMoviesByStatus] = useState>({ toWatch: [], @@ -33,58 +35,54 @@ const MoviesGrid = ({ userId }: Props) => { const movies = moviesByStatus[activeSubTab]; const showBookmark = activeSubTab === 'toWatch'; - const isValidUuid = (val: string | null | undefined) => - !!val && - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - val + const hydrateList = useCallback(async (ids: string[]): Promise => { + const validIds = Array.isArray(ids) ? ids.filter(Boolean) : []; + if (!validIds.length) return []; + + const results = await Promise.all( + validIds.map(async (id) => { + try { + const envelope = await getMovieByCinecircleId(id); + const movie = (envelope as any)?.data ?? (envelope as any)?.movie ?? null; + return { + id, + title: movie?.title || `Movie ${id}`, + poster: movie?.imageUrl ?? null, + } as MovieListItem; + } catch (err) { + console.error('Failed to fetch movie detail:', err); + return { + id, + title: `Movie ${id}`, + poster: null, + } as MovieListItem; + } + }) ); - const fetchMoviesForUser = useCallback(async () => { - if (!userId || !isValidUuid(userId)) { - setMoviesByStatus({ toWatch: [], completed: [] }); - setLoading(false); - setError(null); - return; - } + return results.filter( + (movie, index, self) => self.findIndex((m) => m.id === movie.id) === index + ); + }, []); + + const hydrateFromProfile = useCallback(async () => { try { setLoading(true); setError(null); - const ratingsRes = await getUserRatings(userId); - const ratings = ratingsRes?.ratings ?? []; - - const movieDetails = await Promise.all( - ratings.map(async (rating) => { - try { - const envelope = await getMovieByCinecircleId(rating.movieId); - const movie = (envelope as any)?.data ?? (envelope as any)?.movie ?? null; - - return { - id: rating.movieId, - title: movie?.title || `Movie ${rating.movieId}`, - poster: movie?.imageUrl ?? null, - } as MovieListItem; - } catch (err) { - console.error('Failed to fetch movie detail:', err); - return { - id: rating.movieId, - title: `Movie ${rating.movieId}`, - poster: null, - } as MovieListItem; - } - }) - ); - - const deduped = movieDetails.filter( - (movie, index, self) => self.findIndex((m) => m.id === movie.id) === index - ); + const [toWatchList, completedList] = await Promise.all([ + hydrateList(moviesToWatch ?? []), + hydrateList(moviesCompleted ?? []), + ]); setMoviesByStatus({ - toWatch: [], - completed: deduped, + toWatch: toWatchList, + completed: completedList, }); - if (deduped.length > 0) { + if (toWatchList.length > 0) { + setActiveSubTab('toWatch'); + } else if (completedList.length > 0) { setActiveSubTab('completed'); } } catch (err: any) { @@ -93,17 +91,16 @@ const MoviesGrid = ({ userId }: Props) => { } finally { setLoading(false); } - }, [userId]); + }, [hydrateList, moviesCompleted, moviesToWatch]); useEffect(() => { - fetchMoviesForUser(); - }, [fetchMoviesForUser]); + hydrateFromProfile(); + }, [hydrateFromProfile]); const emptyMessage = useMemo(() => { - if (!userId) return 'Sign in to see your movies.'; if (activeSubTab === 'toWatch') return 'No watchlist movies yet.'; - return 'No movies found for this user.'; - }, [activeSubTab, userId]); + return 'No completed movies yet.'; + }, [activeSubTab]); const renderMovie = ({ item }: { item: MovieListItem }) => ( diff --git a/frontend/app/profilePage/index.tsx b/frontend/app/profilePage/index.tsx index b71cb13..a8665a5 100644 --- a/frontend/app/profilePage/index.tsx +++ b/frontend/app/profilePage/index.tsx @@ -25,7 +25,10 @@ import { getFollowers, getFollowing } from '../../services/followService'; import type { components } from '../../types/api-generated'; import { getUserProfile } from '../../services/userService'; -type UserProfile = components['schemas']['UserProfile']; +type UserProfile = components['schemas']['UserProfile'] & { + moviesToWatch?: string[]; + moviesCompleted?: string[]; +}; type Props = { user?: User; @@ -132,43 +135,45 @@ const ProfilePage = ({ }; }, [fetchProfileData, isMe]); - const resolvedUsername = isMe - ? profile?.username && profile.username.trim().length > 0 - ? profile.username - : 'user' - : userProp?.username && userProp.username.trim().length > 0 - ? userProp.username - : 'user'; + const resolvedDisplayName = isMe + ? profile?.displayName?.trim() || + profile?.username?.trim() || + 'user' + : userProp?.name?.trim() || + userProp?.username?.trim() || + 'user'; const derivedBio = isMe - ? profile?.favoriteMovies?.[0]?.trim() || 'No Bio' + ? profile?.bio?.trim() || + profile?.favoriteMovies?.[0]?.trim() || + 'No Bio' : userProp?.bio || 'No Bio'; const displayUser: User = isMe && profile ? { - name: resolvedUsername || 'User', - username: resolvedUsername || 'user', + name: resolvedDisplayName || 'User', + username: profile.username || 'user', bio: derivedBio, followers: followersCount, following: followingCount, profilePic: profile.profilePicture || `https://ui-avatars.com/api/?name=${encodeURIComponent( - resolvedUsername || 'User' + resolvedDisplayName || 'User' )}&size=200&background=667eea&color=fff`, } : userProp ? { ...userProp, - name: userProp.name || resolvedUsername || 'User', - username: resolvedUsername || 'user', + name: userProp.name || resolvedDisplayName || 'User', + username: userProp.username || resolvedDisplayName || 'user', bio: derivedBio, followers: userProp.followers ?? 0, following: userProp.following ?? 0, profilePic: userProp.profilePic || `https://ui-avatars.com/api/?name=${encodeURIComponent( - resolvedUsername || 'User' + resolvedDisplayName || 'User' )}&size=200&background=667eea&color=fff`, } : { @@ -437,11 +442,6 @@ const ProfilePage = ({ - {/* Activity header */} - - - - {/* Tabs row */} - {activeTab === 'movies' && } + {activeTab === 'movies' && ( + + )} {activeTab === 'posts' && } {activeTab === 'events' && } {activeTab === 'badges' && } diff --git a/frontend/app/profilePage/settings.tsx b/frontend/app/profilePage/settings.tsx index 7a9e633..6d42288 100644 --- a/frontend/app/profilePage/settings.tsx +++ b/frontend/app/profilePage/settings.tsx @@ -19,8 +19,8 @@ import { styles as bottomNavStyles } from '../../styles/BottomNavBar.styles'; export default function Settings() { const [displayName, setDisplayName] = useState(''); - const [bio, setBio] = useState('South Asian cinema enthusiast 🎬 | SRK forever ❤️'); - const [whatsapp, setWhatsapp] = useState('+1 (555) 555-5555'); + const [bio, setBio] = useState(''); + const [whatsapp, setWhatsapp] = useState(''); const [photoUri, setPhotoUri] = useState('https://i.pravatar.cc/150?img=3'); const [hasCustomPhoto, setHasCustomPhoto] = useState(false); const [loading, setLoading] = useState(true); @@ -38,10 +38,12 @@ export default function Settings() { setLoading(true); const res = await getUserProfile(); const username = res.userProfile?.username?.trim() || 'user'; - setDisplayName(username); + const profileDisplayName = res.userProfile?.displayName?.trim() || ''; + setDisplayName(profileDisplayName); const storedBio = - res.userProfile?.favoriteMovies?.[0] || - 'South Asian cinema enthusiast 🎬 | SRK forever ❤️'; + res.userProfile?.bio ?? + res.userProfile?.favoriteMovies?.[0] ?? + ''; setBio(storedBio); const customPhoto = !!res.userProfile?.profilePicture; setHasCustomPhoto(customPhoto); @@ -79,15 +81,16 @@ export default function Settings() { if (saving) return; try { setSaving(true); - const normalizedName = displayName.trim() || 'user'; + const normalizedDisplayName = displayName.trim() || null; await updateUserProfile({ - username: normalizedName, - favoriteMovies: bio.trim() ? [bio.trim()] : [], + displayName: normalizedDisplayName, + bio: bio.trim() || null, }); + const nameForAvatar = normalizedDisplayName || 'user'; if (!hasCustomPhoto) { setPhotoUri( `https://ui-avatars.com/api/?name=${encodeURIComponent( - normalizedName + nameForAvatar )}&size=200&background=667eea&color=fff` ); } diff --git a/frontend/services/userService.ts b/frontend/services/userService.ts index 27fef6f..0332f5d 100644 --- a/frontend/services/userService.ts +++ b/frontend/services/userService.ts @@ -10,6 +10,9 @@ type GetUserProfileResponse = components["schemas"]["GetUserProfileResponse"] & privateAccount?: boolean; spoiler?: boolean; secondaryLanguage?: string[]; + moviesToWatch?: string[]; + moviesCompleted?: string[]; + bio?: string | null; }) | null; }; type GetUserProfileBasicResponse = components["schemas"]["GetUserProfileBasicResponse"]; @@ -18,6 +21,9 @@ type UpdateUserProfileInput = components["schemas"]["UpdateUserProfileInput"] & spoiler?: boolean; secondaryLanguage?: string[]; username?: string; + moviesToWatch?: string[]; + moviesCompleted?: string[]; + bio?: string | null; }; type UpdateUserProfileResponse = components["schemas"]["UpdateUserProfileResponse"]; type DeleteUserProfileResponse = components["schemas"]["DeleteUserProfileResponse"]; From 382854456f4b8628e3529c6e00491fb2c6c030b0 Mon Sep 17 00:00:00 2001 From: tGiech22 Date: Fri, 5 Dec 2025 01:22:29 -0500 Subject: [PATCH 02/12] test: wrote tests to make sure its getting the movies correctly for the user --- .../src/tests/unit/userProfile.unit.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 backend/src/tests/unit/userProfile.unit.test.ts diff --git a/backend/src/tests/unit/userProfile.unit.test.ts b/backend/src/tests/unit/userProfile.unit.test.ts new file mode 100644 index 0000000..503190d --- /dev/null +++ b/backend/src/tests/unit/userProfile.unit.test.ts @@ -0,0 +1,55 @@ +import { mapUserProfileDbToApi, mapUserProfilePatchToUpdateData } from '../../controllers/user'; + +describe('User profile mapping', () => { + it('maps DB payload to API shape including movies lists and displayName/bio', () => { + const now = new Date(); + const api = mapUserProfileDbToApi({ + userId: 'u-1', + username: 'user1', + onboardingCompleted: true, + primaryLanguage: 'English', + secondaryLanguage: ['Spanish'], + profilePicture: null, + country: 'USA', + city: 'NYC', + favoriteGenres: ['Drama'], + favoriteMovies: ['tt0111161'], + displayName: 'User One', + bio: 'Cinephile', + moviesToWatch: ['m1', 'm2'], + moviesCompleted: ['m3'], + privateAccount: false, + spoiler: false, + createdAt: now, + updatedAt: now, + }); + + expect(api.displayName).toBe('User One'); + expect(api.bio).toBe('Cinephile'); + expect(api.moviesToWatch).toEqual(['m1', 'm2']); + expect(api.moviesCompleted).toEqual(['m3']); + }); + + it('builds patch only for provided fields, preserving displayName when not sent', () => { + const data = mapUserProfilePatchToUpdateData({ + username: 'newUser', + favoriteGenres: ['Action'], + }); + + expect(data).toHaveProperty('username', 'newUser'); + expect(data).toHaveProperty('favoriteGenres', ['Action']); + expect(data).not.toHaveProperty('displayName'); + }); + + it('sets displayName and movies lists when provided in patch', () => { + const data = mapUserProfilePatchToUpdateData({ + displayName: 'Shown Name', + moviesToWatch: ['a', 'b'], + moviesCompleted: ['c'], + }); + + expect(data).toHaveProperty('displayName', 'Shown Name'); + expect(data).toHaveProperty('moviesToWatch', ['a', 'b']); + expect(data).toHaveProperty('moviesCompleted', ['c']); + }); +}); From 22c8cbf14418f8cff0011b5a0a7782b1d9fba762 Mon Sep 17 00:00:00 2001 From: tGiech22 Date: Fri, 5 Dec 2025 02:15:36 -0500 Subject: [PATCH 03/12] feat: split events up into saved and attended in schema and updated code and wrote tests --- backend/prisma/schema.prisma | 2 + backend/src/controllers/user.ts | 96 +++++++++++++------ .../src/tests/unit/userProfileEvents.test.ts | 60 ++++++++++++ backend/src/types/apiTypes.ts | 2 + backend/src/types/models.ts | 2 + .../app/profilePage/components/EventsList.tsx | 40 +++++--- frontend/app/profilePage/index.tsx | 10 +- frontend/services/userService.ts | 8 +- 8 files changed, 171 insertions(+), 49 deletions(-) create mode 100644 backend/src/tests/unit/userProfileEvents.test.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d6d9f4e..c3a1464 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -508,6 +508,8 @@ model UserProfile { bio String? @db.Text moviesToWatch String[] @default([]) moviesCompleted String[] @default([]) + eventsSaved String[] @default([]) + eventsAttended String[] @default([]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt Comment Comment[] diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 60eacac..c38b525 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -25,6 +25,8 @@ export const updateUserProfile = async (req: AuthenticatedRequest, res: Response bio, moviesToWatch, moviesCompleted, + eventsSaved, + eventsAttended, } = (req.body ?? {}) as Partial; try { @@ -61,6 +63,8 @@ export const updateUserProfile = async (req: AuthenticatedRequest, res: Response if (bio !== undefined) patch.bio = bio; if (moviesToWatch !== undefined) patch.moviesToWatch = moviesToWatch; if (moviesCompleted !== undefined) patch.moviesCompleted = moviesCompleted; + if (eventsSaved !== undefined) patch.eventsSaved = eventsSaved; + if (eventsAttended !== undefined) patch.eventsAttended = eventsAttended; if (privateAccount !== undefined) patch.privateAccount = privateAccount; if (spoiler !== undefined) patch.spoiler = spoiler; if (updatedAt !== undefined) patch.updatedAt = updatedAt; @@ -125,6 +129,8 @@ export const ensureUserProfile = async (req: AuthenticatedRequest, res: Response bio: null, moviesToWatch: [], moviesCompleted: [], + eventsSaved: [], + eventsAttended: [], updatedAt: new Date(), }, }); @@ -179,25 +185,31 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = profilePicture: userProfile.profilePicture, country: userProfile.country, city: userProfile.city, - displayName: userProfile.displayName, - favoriteGenres: Array.isArray(userProfile.favoriteGenres) - ? userProfile.favoriteGenres as string[] - : [], - favoriteMovies: Array.isArray(userProfile.favoriteMovies) - ? userProfile.favoriteMovies as string[] - : [], - bio: userProfile.bio ?? null, - moviesToWatch: Array.isArray(userProfile.moviesToWatch) - ? (userProfile.moviesToWatch as string[]) - : [], - moviesCompleted: Array.isArray(userProfile.moviesCompleted) - ? (userProfile.moviesCompleted as string[]) - : [], - privateAccount: Boolean(userProfile.privateAccount), - spoiler: Boolean(userProfile.spoiler), - createdAt: userProfile.createdAt, - updatedAt: userProfile.updatedAt, - }); + displayName: userProfile.displayName, + favoriteGenres: Array.isArray(userProfile.favoriteGenres) + ? userProfile.favoriteGenres as string[] + : [], + favoriteMovies: Array.isArray(userProfile.favoriteMovies) + ? userProfile.favoriteMovies as string[] + : [], + bio: userProfile.bio ?? null, + moviesToWatch: Array.isArray(userProfile.moviesToWatch) + ? (userProfile.moviesToWatch as string[]) + : [], + moviesCompleted: Array.isArray(userProfile.moviesCompleted) + ? (userProfile.moviesCompleted as string[]) + : [], + eventsSaved: Array.isArray(userProfile.eventsSaved) + ? (userProfile.eventsSaved as string[]) + : [], + eventsAttended: Array.isArray(userProfile.eventsAttended) + ? (userProfile.eventsAttended as string[]) + : [], + privateAccount: Boolean(userProfile.privateAccount), + spoiler: Boolean(userProfile.spoiler), + createdAt: userProfile.createdAt, + updatedAt: userProfile.updatedAt, + }); const basicUser = req.user ? { @@ -285,6 +297,12 @@ export const getUserRatings = async (req: Request, res: Response): Promise moviesCompleted: Array.isArray(userProfile.moviesCompleted) ? (userProfile.moviesCompleted as string[]) : [], + eventsSaved: Array.isArray(userProfile.eventsSaved) + ? (userProfile.eventsSaved as string[]) + : [], + eventsAttended: Array.isArray(userProfile.eventsAttended) + ? (userProfile.eventsAttended as string[]) + : [], privateAccount: Boolean(userProfile.privateAccount), spoiler: Boolean(userProfile.spoiler), createdAt: userProfile.createdAt, @@ -344,17 +362,23 @@ export const getUserComments = async (req: Request, res: Response): Promise> ): Prisma.UserProfileUpdateInput { const data: Prisma.UserProfileUpdateInput = {}; @@ -461,6 +489,12 @@ export function mapUserProfilePatchToUpdateData( if (Object.prototype.hasOwnProperty.call(patch, "moviesCompleted")) { data.moviesCompleted = patch.moviesCompleted ?? []; } + if (Object.prototype.hasOwnProperty.call(patch, "eventsSaved")) { + data.eventsSaved = patch.eventsSaved ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "eventsAttended")) { + data.eventsAttended = patch.eventsAttended ?? []; + } if (Object.prototype.hasOwnProperty.call(patch, "privateAccount")) { data.privateAccount = patch.privateAccount ?? false; } diff --git a/backend/src/tests/unit/userProfileEvents.test.ts b/backend/src/tests/unit/userProfileEvents.test.ts new file mode 100644 index 0000000..2782a6a --- /dev/null +++ b/backend/src/tests/unit/userProfileEvents.test.ts @@ -0,0 +1,60 @@ +import { mapUserProfileDbToApi, mapUserProfilePatchToUpdateData } from '../../controllers/user.js'; + +describe('UserProfile events fields', () => { + const baseProfile = { + userId: 'user-123', + username: 'tester', + onboardingCompleted: true, + primaryLanguage: 'English', + secondaryLanguage: ['English'], + profilePicture: null, + country: null, + city: null, + favoriteGenres: [], + favoriteMovies: [], + displayName: null, + bio: null, + moviesToWatch: [], + moviesCompleted: [], + eventsSaved: ['event-a', 'event-b'], + eventsAttended: ['event-c'], + privateAccount: false, + spoiler: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + }; + + it('maps saved and attended events through to API shape', () => { + const result = mapUserProfileDbToApi(baseProfile); + expect(result.eventsSaved).toEqual(['event-a', 'event-b']); + expect(result.eventsAttended).toEqual(['event-c']); + }); + + it('defaults missing events arrays to empty arrays', () => { + const result = mapUserProfileDbToApi({ + ...baseProfile, + eventsSaved: null, + eventsAttended: undefined, + }); + expect(result.eventsSaved).toEqual([]); + expect(result.eventsAttended).toEqual([]); + }); + + it('applies patch updates for eventsSaved/eventsAttended', () => { + const patch = mapUserProfilePatchToUpdateData({ + eventsSaved: ['one', 'two'], + eventsAttended: ['three'], + }); + expect(patch.eventsSaved).toEqual(['one', 'two']); + expect(patch.eventsAttended).toEqual(['three']); + }); + + it('clears events arrays when patch sets them to null', () => { + const patch = mapUserProfilePatchToUpdateData({ + eventsSaved: null, + eventsAttended: null, + }); + expect(patch.eventsSaved).toEqual([]); + expect(patch.eventsAttended).toEqual([]); + }); +}); diff --git a/backend/src/types/apiTypes.ts b/backend/src/types/apiTypes.ts index 347b251..89077e9 100644 --- a/backend/src/types/apiTypes.ts +++ b/backend/src/types/apiTypes.ts @@ -59,6 +59,8 @@ export type UpdateUserProfileInput = { spoiler?: boolean; moviesToWatch?: string[]; moviesCompleted?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; }; export type UpdateUserProfileResponse = { message: string; data: UserProfile }; diff --git a/backend/src/types/models.ts b/backend/src/types/models.ts index 816489d..b7f2fde 100644 --- a/backend/src/types/models.ts +++ b/backend/src/types/models.ts @@ -32,6 +32,8 @@ export type UserProfile = { bio?: string | null; moviesToWatch: string[]; moviesCompleted: string[]; + eventsSaved: string[]; + eventsAttended: string[]; privateAccount: boolean; spoiler: boolean; createdAt: Date; diff --git a/frontend/app/profilePage/components/EventsList.tsx b/frontend/app/profilePage/components/EventsList.tsx index 792f5f9..5438f5f 100644 --- a/frontend/app/profilePage/components/EventsList.tsx +++ b/frontend/app/profilePage/components/EventsList.tsx @@ -9,28 +9,35 @@ import { import tw from 'twrnc'; import UpcomingEventCard from '../../events/components/UpcomingEventCard'; import type { LocalEvent } from '../../../services/eventsService'; -import { getUserEvents } from '../../../services/eventsService'; +import { getLocalEvent } from '../../../services/eventsService'; type Props = { userId?: string | null; + eventsSaved?: string[] | null; + eventsAttended?: string[] | null; }; -const EventsList = ({ userId }: Props) => { +const EventsList = ({ userId, eventsSaved = [], eventsAttended = [] }: Props) => { + const savedIds = useMemo(() => eventsSaved ?? [], [eventsSaved]); + const attendedIds = useMemo(() => eventsAttended ?? [], [eventsAttended]); const [activeSubTab, setActiveSubTab] = useState<'saved' | 'attended'>( 'saved' ); const [savedEvents, setSavedEvents] = useState([]); + const [attendedEvents, setAttendedEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const events = useMemo( - () => (activeSubTab === 'saved' ? savedEvents : []), - [activeSubTab, savedEvents] + () => (activeSubTab === 'saved' ? savedEvents : attendedEvents), + [activeSubTab, savedEvents, attendedEvents] ); const loadEvents = useCallback(async () => { - if (!userId) { + // If no ids and no user, nothing to fetch + if ((savedIds.length === 0 && attendedIds.length === 0) && !userId) { setSavedEvents([]); + setAttendedEvents([]); setError(null); setLoading(false); return; @@ -38,15 +45,24 @@ const EventsList = ({ userId }: Props) => { try { setLoading(true); setError(null); - const fetched = await getUserEvents(userId); - setSavedEvents(fetched); + // fetch saved + const fetchSaved = savedIds.length + ? Promise.all(savedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent))) + : []; + const fetchAttended = attendedIds.length + ? Promise.all(attendedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent))) + : []; + + const [saved, attended] = await Promise.all([Promise.all(fetchSaved), Promise.all(fetchAttended)]); + setSavedEvents(saved.filter(Boolean)); + setAttendedEvents(attended.filter(Boolean)); } catch (err: any) { console.error('Failed to load user events', err); setError(err?.message || 'Failed to load events'); } finally { setLoading(false); } - }, [userId]); + }, [userId, savedIds, attendedIds]); useEffect(() => { loadEvents(); @@ -127,13 +143,7 @@ const EventsList = ({ userId }: Props) => { Retry - ) : events.length === 0 ? ( - - {activeSubTab === 'saved' - ? 'No saved events yet.' - : 'No attended events yet.'} - - ) : ( + ) : events.length === 0 ? null : ( item.id} diff --git a/frontend/app/profilePage/index.tsx b/frontend/app/profilePage/index.tsx index a8665a5..fff05db 100644 --- a/frontend/app/profilePage/index.tsx +++ b/frontend/app/profilePage/index.tsx @@ -28,6 +28,8 @@ import { getUserProfile } from '../../services/userService'; type UserProfile = components['schemas']['UserProfile'] & { moviesToWatch?: string[]; moviesCompleted?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; }; type Props = { @@ -549,7 +551,13 @@ const ProfilePage = ({ /> )} {activeTab === 'posts' && } - {activeTab === 'events' && } + {activeTab === 'events' && ( + + )} {activeTab === 'badges' && } diff --git a/frontend/services/userService.ts b/frontend/services/userService.ts index 0332f5d..7914059 100644 --- a/frontend/services/userService.ts +++ b/frontend/services/userService.ts @@ -12,6 +12,8 @@ type GetUserProfileResponse = components["schemas"]["GetUserProfileResponse"] & secondaryLanguage?: string[]; moviesToWatch?: string[]; moviesCompleted?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; bio?: string | null; }) | null; }; @@ -21,8 +23,10 @@ type UpdateUserProfileInput = components["schemas"]["UpdateUserProfileInput"] & spoiler?: boolean; secondaryLanguage?: string[]; username?: string; - moviesToWatch?: string[]; - moviesCompleted?: string[]; + moviesToWatch?: string[]; + moviesCompleted?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; bio?: string | null; }; type UpdateUserProfileResponse = components["schemas"]["UpdateUserProfileResponse"]; From 4c007543b39672b10275bc632d1cbcdf34afa7fe Mon Sep 17 00:00:00 2001 From: tGiech22 Date: Fri, 5 Dec 2025 02:16:43 -0500 Subject: [PATCH 04/12] fix: fix errors in eventslist --- .../app/profilePage/components/EventsList.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/app/profilePage/components/EventsList.tsx b/frontend/app/profilePage/components/EventsList.tsx index 5438f5f..9447e6d 100644 --- a/frontend/app/profilePage/components/EventsList.tsx +++ b/frontend/app/profilePage/components/EventsList.tsx @@ -45,17 +45,20 @@ const EventsList = ({ userId, eventsSaved = [], eventsAttended = [] }: Props) => try { setLoading(true); setError(null); - // fetch saved - const fetchSaved = savedIds.length - ? Promise.all(savedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent))) + // fetch saved (create arrays of promises) + const fetchSaved: Promise[] = savedIds.length + ? savedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent)) : []; - const fetchAttended = attendedIds.length - ? Promise.all(attendedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent))) + const fetchAttended: Promise[] = attendedIds.length + ? attendedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent)) : []; - const [saved, attended] = await Promise.all([Promise.all(fetchSaved), Promise.all(fetchAttended)]); - setSavedEvents(saved.filter(Boolean)); - setAttendedEvents(attended.filter(Boolean)); + const [saved, attended] = await Promise.all([ + Promise.all(fetchSaved), + Promise.all(fetchAttended), + ]); + setSavedEvents(saved.filter((e): e is LocalEvent => Boolean(e))); + setAttendedEvents(attended.filter((e): e is LocalEvent => Boolean(e))); } catch (err: any) { console.error('Failed to load user events', err); setError(err?.message || 'Failed to load events'); From 8baed49ae8e350c8eb5baa6f823092b869999d6b Mon Sep 17 00:00:00 2001 From: tGiech22 Date: Fri, 5 Dec 2025 02:20:11 -0500 Subject: [PATCH 05/12] fix: removed whatsapp from edit profile settings --- frontend/app/profilePage/settings.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/frontend/app/profilePage/settings.tsx b/frontend/app/profilePage/settings.tsx index 6d42288..553306d 100644 --- a/frontend/app/profilePage/settings.tsx +++ b/frontend/app/profilePage/settings.tsx @@ -20,7 +20,6 @@ import { styles as bottomNavStyles } from '../../styles/BottomNavBar.styles'; export default function Settings() { const [displayName, setDisplayName] = useState(''); const [bio, setBio] = useState(''); - const [whatsapp, setWhatsapp] = useState(''); const [photoUri, setPhotoUri] = useState('https://i.pravatar.cc/150?img=3'); const [hasCustomPhoto, setHasCustomPhoto] = useState(false); const [loading, setLoading] = useState(true); @@ -248,21 +247,6 @@ export default function Settings() { ]} /> - - {/* WhatsApp Section */} - - WhatsApp Number - - )} From 3f427b393cdc3141465df0314186c59cbba5df12 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:07:18 -0500 Subject: [PATCH 06/12] Seed updates, type fixes --- backend/prisma/seed.sql | 88 ++++++++- .../app/profilePage/components/MoviesGrid.tsx | 85 +++++---- frontend/types/api-generated.ts | 174 +++++++++++++++++- 3 files changed, 296 insertions(+), 51 deletions(-) diff --git a/backend/prisma/seed.sql b/backend/prisma/seed.sql index c157583..0b0971d 100644 --- a/backend/prisma/seed.sql +++ b/backend/prisma/seed.sql @@ -42,21 +42,89 @@ INSERT INTO "public"."UserProfile" ( "profilePicture", "country", "city", + "displayName", "favoriteGenres", "favoriteMovies", + "privateAccount", + "spoiler", + "bio", + "moviesToWatch", + "moviesCompleted", + "eventsSaved", + "eventsAttended", "createdAt", "updatedAt" ) VALUES - ('11111111-1111-1111-1111-111111111111', 'alice_movie_fan', true, 'English', ARRAY['Spanish'], NULL, 'USA', 'New York', ARRAY['Drama', 'Thriller'], ARRAY['tt0111161', 'tt0068646'], NOW(), NOW()), - ('22222222-2222-2222-2222-222222222222', 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', ARRAY['Action', 'Sci-Fi'], ARRAY['tt0468569', 'tt0137523'], NOW(), NOW()), - ('33333333-3333-3333-3333-333333333333', 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', ARRAY['Comedy', 'Romance'], ARRAY['tt0109830', 'tt1375666'], NOW(), NOW()), - ('44444444-4444-4444-4444-444444444444', 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', ARRAY['Drama', 'Biography'], ARRAY['tt0073486', 'tt0099685'], NOW(), NOW()), - ('55555555-5555-5555-5555-555555555555', 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', ARRAY['Animation', 'Fantasy'], ARRAY['tt0245429', 'tt1853728'], NOW(), NOW()), - ('66666666-6666-6666-6666-666666666666', 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', ARRAY['Horror', 'Mystery'], ARRAY['tt0816692', 'tt0110912'], NOW(), NOW()), - ('77777777-7777-7777-7777-777777777777', 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', ARRAY['Western', 'Crime'], ARRAY['tt0076759', 'tt0050083'], NOW(), NOW()), - ('88888888-8888-8888-8888-888888888888', 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', ARRAY['Drama', 'Thriller'], ARRAY['tt6751668', 'tt0167260'], NOW(), NOW()), - ('99999999-9999-9999-9999-999999999999', 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', ARRAY['Independent', 'Documentary'], ARRAY['tt0114369', 'tt0120737'], NOW(), NOW()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', ARRAY['Drama', 'Romance'], ARRAY['tt0133093', 'tt0088763'], NOW(), NOW()) + ('11111111-1111-1111-1111-111111111111', + 'alice_movie_fan', true, 'English', ARRAY['Spanish'], NULL, 'USA', 'New York', + 'Alice', ARRAY['Drama','Thriller'], ARRAY['tt0111161','tt0068646'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('22222222-2222-2222-2222-222222222222', + 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', + 'Bob', ARRAY['Action','Sci-Fi'], ARRAY['tt0468569','tt0137523'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('33333333-3333-3333-3333-333333333333', + 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', + 'Charlie', ARRAY['Comedy','Romance'], ARRAY['tt0109830','tt1375666'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('44444444-4444-4444-4444-444444444444', + 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', + 'Diana', ARRAY['Drama','Biography'], ARRAY['tt0073486','tt0099685'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('55555555-5555-5555-5555-555555555555', + 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', + 'Evan', ARRAY['Animation','Fantasy'], ARRAY['tt0245429','tt1853728'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('66666666-6666-6666-6666-666666666666', + 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', + 'Fiona', ARRAY['Horror','Mystery'], ARRAY['tt0816692','tt0110912'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('77777777-7777-7777-7777-777777777777', + 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', + 'George', ARRAY['Western','Crime'], ARRAY['tt0076759','tt0050083'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('88888888-8888-8888-8888-888888888888', + 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', + 'Hannah', ARRAY['Drama','Thriller'], ARRAY['tt6751668','tt0167260'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('99999999-9999-9999-9999-999999999999', + 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', + 'Isaac', ARRAY['Independent','Documentary'], ARRAY['tt0114369','tt0120737'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', + 'Julia', ARRAY['Drama','Romance'], ARRAY['tt0133093','tt0088763'], + false, false, NULL, + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + NOW(), NOW() + ) ON CONFLICT ("userId") DO NOTHING; -- ============================================ diff --git a/frontend/app/profilePage/components/MoviesGrid.tsx b/frontend/app/profilePage/components/MoviesGrid.tsx index 01a9218..a18b52e 100644 --- a/frontend/app/profilePage/components/MoviesGrid.tsx +++ b/frontend/app/profilePage/components/MoviesGrid.tsx @@ -25,8 +25,12 @@ type Props = { }; const MoviesGrid = ({ userId, moviesToWatch, moviesCompleted }: Props) => { - const [activeSubTab, setActiveSubTab] = useState<'toWatch' | 'completed'>('completed'); - const [moviesByStatus, setMoviesByStatus] = useState>({ + const [activeSubTab, setActiveSubTab] = useState<'toWatch' | 'completed'>( + 'completed' + ); + const [moviesByStatus, setMoviesByStatus] = useState< + Record<'toWatch' | 'completed', MovieListItem[]> + >({ toWatch: [], completed: [], }); @@ -35,35 +39,39 @@ const MoviesGrid = ({ userId, moviesToWatch, moviesCompleted }: Props) => { const movies = moviesByStatus[activeSubTab]; const showBookmark = activeSubTab === 'toWatch'; - const hydrateList = useCallback(async (ids: string[]): Promise => { - const validIds = Array.isArray(ids) ? ids.filter(Boolean) : []; - if (!validIds.length) return []; - - const results = await Promise.all( - validIds.map(async (id) => { - try { - const envelope = await getMovieByCinecircleId(id); - const movie = (envelope as any)?.data ?? (envelope as any)?.movie ?? null; - return { - id, - title: movie?.title || `Movie ${id}`, - poster: movie?.imageUrl ?? null, - } as MovieListItem; - } catch (err) { - console.error('Failed to fetch movie detail:', err); - return { - id, - title: `Movie ${id}`, - poster: null, - } as MovieListItem; - } - }) - ); - - return results.filter( - (movie, index, self) => self.findIndex((m) => m.id === movie.id) === index - ); - }, []); + const hydrateList = useCallback( + async (ids: string[]): Promise => { + const validIds = Array.isArray(ids) ? ids.filter(Boolean) : []; + if (!validIds.length) return []; + + const results = await Promise.all( + validIds.map(async id => { + try { + const envelope = await getMovieByCinecircleId(id); + const movie = + (envelope as any)?.data ?? (envelope as any)?.movie ?? null; + return { + id, + title: movie?.title || `Movie ${id}`, + poster: movie?.imageUrl ?? null, + } as MovieListItem; + } catch (err) { + console.error('Failed to fetch movie detail:', err); + return { + id, + title: `Movie ${id}`, + poster: null, + } as MovieListItem; + } + }) + ); + + return results.filter( + (movie, index, self) => self.findIndex(m => m.id === movie.id) === index + ); + }, + [] + ); const hydrateFromProfile = useCallback(async () => { try { @@ -117,7 +125,10 @@ const MoviesGrid = ({ userId, moviesToWatch, moviesCompleted }: Props) => { )} - + {item.title} { paddingVertical: 8, borderRadius: 8, marginHorizontal: 2, - backgroundColor: activeSubTab === 'toWatch' ? '#D62E05' : 'transparent', + backgroundColor: + activeSubTab === 'toWatch' ? '#D62E05' : 'transparent', }, ]} onPress={() => setActiveSubTab('toWatch')} @@ -168,7 +180,8 @@ const MoviesGrid = ({ userId, moviesToWatch, moviesCompleted }: Props) => { paddingVertical: 8, borderRadius: 8, marginHorizontal: 2, - backgroundColor: activeSubTab === 'completed' ? '#D62E05' : 'transparent', + backgroundColor: + activeSubTab === 'completed' ? '#D62E05' : 'transparent', }, ]} onPress={() => setActiveSubTab('completed')} @@ -193,7 +206,7 @@ const MoviesGrid = ({ userId, moviesToWatch, moviesCompleted }: Props) => { {error} Retry @@ -204,7 +217,7 @@ const MoviesGrid = ({ userId, moviesToWatch, moviesCompleted }: Props) => { ) : ( item.id} + keyExtractor={item => item.id} renderItem={renderMovie} scrollEnabled={false} removeClippedSubviews={false} diff --git a/frontend/types/api-generated.ts b/frontend/types/api-generated.ts index ead4624..946e979 100644 --- a/frontend/types/api-generated.ts +++ b/frontend/types/api-generated.ts @@ -421,11 +421,27 @@ export interface paths { /** @example any */ city?: unknown; /** @example any */ + displayName?: unknown; + /** @example any */ favoriteGenres?: unknown; /** @example any */ favoriteMovies?: unknown; /** @example any */ updatedAt?: unknown; + /** @example any */ + privateAccount?: unknown; + /** @example any */ + spoiler?: unknown; + /** @example any */ + bio?: unknown; + /** @example any */ + moviesToWatch?: unknown; + /** @example any */ + moviesCompleted?: unknown; + /** @example any */ + eventsSaved?: unknown; + /** @example any */ + eventsAttended?: unknown; }; }; }; @@ -444,6 +460,13 @@ export interface paths { }; content?: never; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Internal Server Error */ 500: { headers: { @@ -1333,6 +1356,136 @@ export interface paths { patch?: never; trace?: never; }; + "/api/comment/{id}/like": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: { + authorization?: string; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/comment/{id}/likes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: { + authorization?: string; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/{movieId}/comments": { parameters: { query?: never; @@ -3487,8 +3640,16 @@ export interface components { profilePicture: string | null; country: string | null; city: string | null; + displayName?: string | null; favoriteGenres: string[]; favoriteMovies: string[]; + bio?: string | null; + moviesToWatch: string[]; + moviesCompleted: string[]; + eventsSaved: string[]; + eventsAttended: string[]; + privateAccount: boolean; + spoiler: boolean; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -3502,8 +3663,16 @@ export interface components { secondaryLanguage?: string[]; country?: string; city?: string; + displayName?: string | null; favoriteGenres?: string[]; favoriteMovies?: string[]; + bio?: string | null; + privateAccount?: boolean; + spoiler?: boolean; + moviesToWatch?: string[]; + moviesCompleted?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; }; UpdateUserProfileResponse: { message: string; @@ -3526,11 +3695,6 @@ export interface components { tags: string[]; date: string; votes: number; - UserProfile?: { - userId: string; - username: string | null; - }; - threadedComments?: unknown[]; }; GetUserCommentsResponse: { message: string; From 7d8a82bd0291377178535a0fb091b30604d6ec17 Mon Sep 17 00:00:00 2001 From: tGiech22 Date: Fri, 5 Dec 2025 12:52:01 -0500 Subject: [PATCH 07/12] fix: removing moviesToWatch and moviesCompleted stuff from backend --- backend/prisma/schema.prisma | 2 -- backend/prisma/seed.sql | 26 ++++++-------- backend/src/controllers/user.ts | 36 +------------------ .../src/tests/unit/userProfile.unit.test.ts | 16 ++++----- .../src/tests/unit/userProfileEvents.test.ts | 2 -- backend/src/types/apiTypes.ts | 2 -- backend/src/types/models.ts | 2 -- 7 files changed, 18 insertions(+), 68 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c3a1464..bc5dbe3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -506,8 +506,6 @@ model UserProfile { privateAccount Boolean @default(false) spoiler Boolean @default(false) bio String? @db.Text - moviesToWatch String[] @default([]) - moviesCompleted String[] @default([]) eventsSaved String[] @default([]) eventsAttended String[] @default([]) createdAt DateTime @default(now()) diff --git a/backend/prisma/seed.sql b/backend/prisma/seed.sql index c44764b..340ee27 100644 --- a/backend/prisma/seed.sql +++ b/backend/prisma/seed.sql @@ -48,82 +48,79 @@ INSERT INTO "public"."UserProfile" ( "privateAccount", "spoiler", "bio", - "moviesToWatch", - "moviesCompleted", "eventsSaved", "eventsAttended", "createdAt", - "updatedAt", - "spoiler" + "updatedAt" ) VALUES ('11111111-1111-1111-1111-111111111111', 'alice_movie_fan', true, 'English', ARRAY['Spanish'], NULL, 'USA', 'New York', 'Alice', ARRAY['Drama','Thriller'], ARRAY['tt0111161','tt0068646'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('22222222-2222-2222-2222-222222222222', 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', 'Bob', ARRAY['Action','Sci-Fi'], ARRAY['tt0468569','tt0137523'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('33333333-3333-3333-3333-333333333333', 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', 'Charlie', ARRAY['Comedy','Romance'], ARRAY['tt0109830','tt1375666'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('44444444-4444-4444-4444-444444444444', 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', 'Diana', ARRAY['Drama','Biography'], ARRAY['tt0073486','tt0099685'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('55555555-5555-5555-5555-555555555555', 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', 'Evan', ARRAY['Animation','Fantasy'], ARRAY['tt0245429','tt1853728'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('66666666-6666-6666-6666-666666666666', 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', 'Fiona', ARRAY['Horror','Mystery'], ARRAY['tt0816692','tt0110912'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('77777777-7777-7777-7777-777777777777', 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', 'George', ARRAY['Western','Crime'], ARRAY['tt0076759','tt0050083'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('88888888-8888-8888-8888-888888888888', 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', 'Hannah', ARRAY['Drama','Thriller'], ARRAY['tt6751668','tt0167260'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('99999999-9999-9999-9999-999999999999', 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', 'Isaac', ARRAY['Independent','Documentary'], ARRAY['tt0114369','tt0120737'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ), ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', 'Julia', ARRAY['Drama','Romance'], ARRAY['tt0133093','tt0088763'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], NOW(), NOW() ) ON CONFLICT ("userId") DO NOTHING; @@ -801,4 +798,3 @@ ON CONFLICT ("id") DO NOTHING; -- Success Message -- ============================================ SELECT 'Seed data inserted successfully!' as message; - diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index c38b525..337c511 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -23,8 +23,6 @@ export const updateUserProfile = async (req: AuthenticatedRequest, res: Response privateAccount, spoiler, bio, - moviesToWatch, - moviesCompleted, eventsSaved, eventsAttended, } = (req.body ?? {}) as Partial; @@ -61,8 +59,6 @@ export const updateUserProfile = async (req: AuthenticatedRequest, res: Response if (favoriteGenres !== undefined) patch.favoriteGenres = favoriteGenres; if (favoriteMovies !== undefined) patch.favoriteMovies = favoriteMovies; if (bio !== undefined) patch.bio = bio; - if (moviesToWatch !== undefined) patch.moviesToWatch = moviesToWatch; - if (moviesCompleted !== undefined) patch.moviesCompleted = moviesCompleted; if (eventsSaved !== undefined) patch.eventsSaved = eventsSaved; if (eventsAttended !== undefined) patch.eventsAttended = eventsAttended; if (privateAccount !== undefined) patch.privateAccount = privateAccount; @@ -127,8 +123,6 @@ export const ensureUserProfile = async (req: AuthenticatedRequest, res: Response privateAccount: false, spoiler: false, bio: null, - moviesToWatch: [], - moviesCompleted: [], eventsSaved: [], eventsAttended: [], updatedAt: new Date(), @@ -193,12 +187,6 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = ? userProfile.favoriteMovies as string[] : [], bio: userProfile.bio ?? null, - moviesToWatch: Array.isArray(userProfile.moviesToWatch) - ? (userProfile.moviesToWatch as string[]) - : [], - moviesCompleted: Array.isArray(userProfile.moviesCompleted) - ? (userProfile.moviesCompleted as string[]) - : [], eventsSaved: Array.isArray(userProfile.eventsSaved) ? (userProfile.eventsSaved as string[]) : [], @@ -291,12 +279,6 @@ export const getUserRatings = async (req: Request, res: Response): Promise ? userProfile.favoriteMovies as string[] : [], bio: userProfile.bio ?? null, - moviesToWatch: Array.isArray(userProfile.moviesToWatch) - ? (userProfile.moviesToWatch as string[]) - : [], - moviesCompleted: Array.isArray(userProfile.moviesCompleted) - ? (userProfile.moviesCompleted as string[]) - : [], eventsSaved: Array.isArray(userProfile.eventsSaved) ? (userProfile.eventsSaved as string[]) : [], @@ -362,12 +344,6 @@ export const getUserComments = async (req: Request, res: Response): Promise> ): Prisma.UserProfileUpdateInput { const data: Prisma.UserProfileUpdateInput = {}; @@ -483,12 +455,6 @@ export function mapUserProfilePatchToUpdateData( if (Object.prototype.hasOwnProperty.call(patch, "bio")) { data.bio = patch.bio ?? null; } - if (Object.prototype.hasOwnProperty.call(patch, "moviesToWatch")) { - data.moviesToWatch = patch.moviesToWatch ?? []; - } - if (Object.prototype.hasOwnProperty.call(patch, "moviesCompleted")) { - data.moviesCompleted = patch.moviesCompleted ?? []; - } if (Object.prototype.hasOwnProperty.call(patch, "eventsSaved")) { data.eventsSaved = patch.eventsSaved ?? []; } diff --git a/backend/src/tests/unit/userProfile.unit.test.ts b/backend/src/tests/unit/userProfile.unit.test.ts index 503190d..72a8f25 100644 --- a/backend/src/tests/unit/userProfile.unit.test.ts +++ b/backend/src/tests/unit/userProfile.unit.test.ts @@ -1,7 +1,7 @@ import { mapUserProfileDbToApi, mapUserProfilePatchToUpdateData } from '../../controllers/user'; describe('User profile mapping', () => { - it('maps DB payload to API shape including movies lists and displayName/bio', () => { + it('maps DB payload to API shape including displayName/bio', () => { const now = new Date(); const api = mapUserProfileDbToApi({ userId: 'u-1', @@ -16,8 +16,6 @@ describe('User profile mapping', () => { favoriteMovies: ['tt0111161'], displayName: 'User One', bio: 'Cinephile', - moviesToWatch: ['m1', 'm2'], - moviesCompleted: ['m3'], privateAccount: false, spoiler: false, createdAt: now, @@ -26,8 +24,6 @@ describe('User profile mapping', () => { expect(api.displayName).toBe('User One'); expect(api.bio).toBe('Cinephile'); - expect(api.moviesToWatch).toEqual(['m1', 'm2']); - expect(api.moviesCompleted).toEqual(['m3']); }); it('builds patch only for provided fields, preserving displayName when not sent', () => { @@ -41,15 +37,15 @@ describe('User profile mapping', () => { expect(data).not.toHaveProperty('displayName'); }); - it('sets displayName and movies lists when provided in patch', () => { + it('sets displayName and event lists when provided in patch', () => { const data = mapUserProfilePatchToUpdateData({ displayName: 'Shown Name', - moviesToWatch: ['a', 'b'], - moviesCompleted: ['c'], + eventsSaved: ['a', 'b'], + eventsAttended: ['c'], }); expect(data).toHaveProperty('displayName', 'Shown Name'); - expect(data).toHaveProperty('moviesToWatch', ['a', 'b']); - expect(data).toHaveProperty('moviesCompleted', ['c']); + expect(data).toHaveProperty('eventsSaved', ['a', 'b']); + expect(data).toHaveProperty('eventsAttended', ['c']); }); }); diff --git a/backend/src/tests/unit/userProfileEvents.test.ts b/backend/src/tests/unit/userProfileEvents.test.ts index 2782a6a..1aa6f07 100644 --- a/backend/src/tests/unit/userProfileEvents.test.ts +++ b/backend/src/tests/unit/userProfileEvents.test.ts @@ -14,8 +14,6 @@ describe('UserProfile events fields', () => { favoriteMovies: [], displayName: null, bio: null, - moviesToWatch: [], - moviesCompleted: [], eventsSaved: ['event-a', 'event-b'], eventsAttended: ['event-c'], privateAccount: false, diff --git a/backend/src/types/apiTypes.ts b/backend/src/types/apiTypes.ts index 89077e9..ce14f8a 100644 --- a/backend/src/types/apiTypes.ts +++ b/backend/src/types/apiTypes.ts @@ -57,8 +57,6 @@ export type UpdateUserProfileInput = { bio?: string | null; privateAccount?: boolean; spoiler?: boolean; - moviesToWatch?: string[]; - moviesCompleted?: string[]; eventsSaved?: string[]; eventsAttended?: string[]; }; diff --git a/backend/src/types/models.ts b/backend/src/types/models.ts index b7f2fde..d9061c6 100644 --- a/backend/src/types/models.ts +++ b/backend/src/types/models.ts @@ -30,8 +30,6 @@ export type UserProfile = { favoriteGenres: string[]; favoriteMovies: string[]; bio?: string | null; - moviesToWatch: string[]; - moviesCompleted: string[]; eventsSaved: string[]; eventsAttended: string[]; privateAccount: boolean; From 6593f7c58101464e6681b629d6ffce19411dc684 Mon Sep 17 00:00:00 2001 From: tGiech22 Date: Fri, 5 Dec 2025 14:47:47 -0500 Subject: [PATCH 08/12] feat: posts from backend and events as well --- backend/prisma/schema.prisma | 198 +++++++++--------- backend/prisma/seed.sql | 30 ++- backend/src/controllers/post.ts | 15 +- backend/src/controllers/search.ts | 50 ++++- backend/src/controllers/user.ts | 57 +++++ backend/src/routes/index.ts | 3 +- .../app/profilePage/components/EventsList.tsx | 9 +- frontend/app/profilePage/index.tsx | 10 +- frontend/app/profilePage/user/[userId].tsx | 55 ++++- frontend/services/searchService.ts | 21 +- frontend/services/userService.ts | 4 + 11 files changed, 318 insertions(+), 134 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bc5dbe3..0930a21 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -111,20 +111,21 @@ model mfa_challenges { /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. model mfa_factors { - id String @id @db.Uuid - user_id String @db.Uuid - friendly_name String? - factor_type factor_type - status factor_status - created_at DateTime @db.Timestamptz(6) - updated_at DateTime @db.Timestamptz(6) - secret String? - phone String? - last_challenged_at DateTime? @unique @db.Timestamptz(6) - web_authn_credential Json? - web_authn_aaguid String? @db.Uuid - mfa_challenges mfa_challenges[] - users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + id String @id @db.Uuid + user_id String @db.Uuid + friendly_name String? + factor_type factor_type + status factor_status + created_at DateTime @db.Timestamptz(6) + updated_at DateTime @db.Timestamptz(6) + secret String? + phone String? + last_challenged_at DateTime? @unique @db.Timestamptz(6) + web_authn_credential Json? + web_authn_aaguid String? @db.Uuid + last_webauthn_challenge_data Json? + mfa_challenges mfa_challenges[] + users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@unique([user_id, phone], map: "unique_phone_factor_per_user") @@index([user_id, created_at], map: "factor_id_created_at_idx") @@ -150,6 +151,7 @@ model oauth_authorizations { created_at DateTime @default(now()) @db.Timestamptz(6) expires_at DateTime @default(dbgenerated("(now() + '00:03:00'::interval)")) @db.Timestamptz(6) approved_at DateTime? @db.Timestamptz(6) + nonce String? oauth_clients oauth_clients @relation(fields: [client_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users auth_users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@ -282,25 +284,29 @@ model schema_migrations { @@schema("auth") } +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. model sessions { - id String @id @db.Uuid - user_id String @db.Uuid - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - factor_id String? @db.Uuid - aal aal_level? - not_after DateTime? @db.Timestamptz(6) - refreshed_at DateTime? @db.Timestamp(6) - user_agent String? - ip String? @db.Inet - tag String? - oauth_client_id String? @db.Uuid - mfa_amr_claims mfa_amr_claims[] - refresh_tokens refresh_tokens[] - oauth_clients oauth_clients? @relation(fields: [oauth_client_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + id String @id @db.Uuid + user_id String @db.Uuid + created_at DateTime? @db.Timestamptz(6) + updated_at DateTime? @db.Timestamptz(6) + factor_id String? @db.Uuid + aal aal_level? + not_after DateTime? @db.Timestamptz(6) + refreshed_at DateTime? @db.Timestamp(6) + user_agent String? + ip String? @db.Inet + tag String? + oauth_client_id String? @db.Uuid + refresh_token_hmac_key String? + refresh_token_counter BigInt? + scopes String? + mfa_amr_claims mfa_amr_claims[] + refresh_tokens refresh_tokens[] + oauth_clients oauth_clients? @relation(fields: [oauth_client_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@index([not_after(sort: Desc)]) @@index([oauth_client_id]) @@ -397,23 +403,23 @@ model auth_users { } model Comment { - id String @id @default(uuid()) - userId String @db.Uuid - postId String? - content String - createdAt DateTime @default(now()) - parentId String? - parent_comment Comment? @relation("CommentToComment", fields: [parentId], references: [id], onDelete: SetNull) - child_comment Comment[] @relation("CommentToComment") - Post Post? @relation(fields: [postId], references: [id]) - UserProfile UserProfile @relation(fields: [userId], references: [userId]) - CommentLike CommentLike[] + id String @id + userId String @db.Uuid + postId String? + content String + createdAt DateTime @default(now()) + parentId String? + Comment Comment? @relation("CommentToComment", fields: [parentId], references: [id]) + other_Comment Comment[] @relation("CommentToComment") + Post Post? @relation(fields: [postId], references: [id]) + UserProfile UserProfile @relation(fields: [userId], references: [userId]) + CommentLike CommentLike[] @@schema("public") } model CommentLike { - id String @id @default(uuid()) + id String @id commentId String userId String @db.Uuid createdAt DateTime @default(now()) @@ -424,34 +430,30 @@ model CommentLike { @@schema("public") } - -// Post model supports both SHORT and LONG posts about movies -// SHORT posts: <=280 chars, no stars -// LONG posts: >280 chars, optional stars (reviews) model Post { - id String @id @default(uuid()) - userId String @db.Uuid - movieId String // Required - all posts must reference a movie - content String - type PostType // SHORT or LONG - stars Int? // Optional star rating (0-10), only for LONG posts - spoiler Boolean @default(false) // Spoiler flag - tags String[] @default([]) // Movie tags from preset list - createdAt DateTime @default(now()) - imageUrls String[] @default([]) - repostedPostId String? // For reposts/shares - references original post - Comment Comment[] - UserProfile UserProfile @relation(fields: [userId], references: [userId]) - PostReaction PostReaction[] // Track individual reactions - OriginalPost Post? @relation("PostReposts", fields: [repostedPostId], references: [id], onDelete: SetNull) - Reposts Post[] @relation("PostReposts") - movie movie @relation(fields: [movieId], references: [movieId]) + id String @id + userId String @db.Uuid + movieId String + content String + type PostType + stars Int? + spoiler Boolean @default(false) + tags String[] @default([]) + createdAt DateTime @default(now()) + imageUrls String[] @default([]) + repostedPostId String? + Comment Comment[] + movie movie @relation(fields: [movieId], references: [movieId]) + Post Post? @relation("PostToPost", fields: [repostedPostId], references: [id]) + other_Post Post[] @relation("PostToPost") + UserProfile UserProfile @relation(fields: [userId], references: [userId]) + PostReaction PostReaction[] @@schema("public") } model PostReaction { - id String @id @default(uuid()) + id String @id postId String userId String @db.Uuid reactionType ReactionType @@ -463,19 +465,15 @@ model PostReaction { @@schema("public") } -// DEPRECATED: Use Post model instead for all movie content -// This model is kept for backward compatibility only -// All relations removed - this is a standalone legacy model -// DO NOT USE THIS MODEL model Rating { - id String @id @default(uuid()) - userId String @db.Uuid - movieId String - stars Int - comment String? - tags String[] @default([]) - date DateTime - votes Int @default(0) + id String @id + userId String @db.Uuid + movieId String + stars Int + comment String? + tags String[] @default([]) + date DateTime + votes Int @default(0) @@schema("public") } @@ -505,11 +503,13 @@ model UserProfile { favoriteMovies String[] @default([]) privateAccount Boolean @default(false) spoiler Boolean @default(false) - bio String? @db.Text + bio String? + moviesToWatch String[] @default([]) + moviesCompleted String[] @default([]) eventsSaved String[] @default([]) eventsAttended String[] @default([]) createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + updatedAt DateTime Comment Comment[] CommentLike CommentLike[] Post Post[] @@ -530,8 +530,22 @@ model bootcamp { @@schema("public") } +model event_rsvp { + id String @id @db.Uuid + eventId String @db.Uuid + userId String @db.Uuid + status String + createdAt DateTime @default(now()) + updatedAt DateTime + local_event local_event @relation(fields: [eventId], references: [id], onDelete: Cascade) + UserProfile UserProfile @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([eventId, userId]) + @@schema("public") +} + model local_event { - id String @id(map: "local_events_pkey") @default(uuid()) @db.Uuid + id String @id(map: "local_events_pkey") @db.Uuid title String time DateTime? @default(dbgenerated("(now() AT TIME ZONE 'utc'::text)")) @db.Timestamptz(6) description String @@ -547,22 +561,8 @@ model local_event { @@schema("public") } -model event_rsvp { - id String @id @default(uuid()) @db.Uuid - eventId String @db.Uuid - userId String @db.Uuid - status String // 'yes', 'maybe', 'no' - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - local_event local_event @relation(fields: [eventId], references: [id], onDelete: Cascade) - UserProfile UserProfile @relation(fields: [userId], references: [userId], onDelete: Cascade) - - @@unique([eventId, userId]) - @@schema("public") -} - model movie { - movieId String @id + movieId String @id localRating String? imdbRating BigInt? languages Json? @@ -572,7 +572,7 @@ model movie { imageUrl String? releaseYear Int? director String? - Post Post[] // All movie posts (SHORT and LONG) + Post Post[] @@schema("public") } @@ -664,10 +664,10 @@ enum PostType { } enum ReactionType { - SPICY // 🌶️ Drama-filled, bold, or full of tension - STAR_STUDDED // ✨ Packed with A-listers - THOUGHT_PROVOKING // 🧠 Thought-provoking/mind blowing - BLOCKBUSTER // 🧨 Mega-hit with hype, memes, and madness + SPICY + STAR_STUDDED + THOUGHT_PROVOKING + BLOCKBUSTER @@schema("public") } diff --git a/backend/prisma/seed.sql b/backend/prisma/seed.sql index 340ee27..c561a09 100644 --- a/backend/prisma/seed.sql +++ b/backend/prisma/seed.sql @@ -57,70 +57,80 @@ INSERT INTO "public"."UserProfile" ( 'alice_movie_fan', true, 'English', ARRAY['Spanish'], NULL, 'USA', 'New York', 'Alice', ARRAY['Drama','Thriller'], ARRAY['tt0111161','tt0068646'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e1111111-1111-1111-1111-111111111111','e4444444-4444-4444-4444-444444444444'], + ARRAY['e1111111-1111-1111-1111-111111111111','e5555555-5555-5555-5555-555555555555'], NOW(), NOW() ), ('22222222-2222-2222-2222-222222222222', 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', 'Bob', ARRAY['Action','Sci-Fi'], ARRAY['tt0468569','tt0137523'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e2222222-2222-2222-2222-222222222222','e3333333-3333-3333-3333-333333333333'], + ARRAY['e1111111-1111-1111-1111-111111111111','e2222222-2222-2222-2222-222222222222'], NOW(), NOW() ), ('33333333-3333-3333-3333-333333333333', 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', 'Charlie', ARRAY['Comedy','Romance'], ARRAY['tt0109830','tt1375666'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e3333333-3333-3333-3333-333333333333'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333'], NOW(), NOW() ), ('44444444-4444-4444-4444-444444444444', 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', 'Diana', ARRAY['Drama','Biography'], ARRAY['tt0073486','tt0099685'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e4444444-4444-4444-4444-444444444444'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], NOW(), NOW() ), ('55555555-5555-5555-5555-555555555555', 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', 'Evan', ARRAY['Animation','Fantasy'], ARRAY['tt0245429','tt1853728'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e1111111-1111-1111-1111-111111111111','e5555555-5555-5555-5555-555555555555'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], NOW(), NOW() ), ('66666666-6666-6666-6666-666666666666', 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', 'Fiona', ARRAY['Horror','Mystery'], ARRAY['tt0816692','tt0110912'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333'], NOW(), NOW() ), ('77777777-7777-7777-7777-777777777777', 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', 'George', ARRAY['Western','Crime'], ARRAY['tt0076759','tt0050083'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e2222222-2222-2222-2222-222222222222','e5555555-5555-5555-5555-555555555555'], + ARRAY['e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], NOW(), NOW() ), ('88888888-8888-8888-8888-888888888888', 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', 'Hannah', ARRAY['Drama','Thriller'], ARRAY['tt6751668','tt0167260'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e2222222-2222-2222-2222-222222222222','e4444444-4444-4444-4444-444444444444'], + ARRAY['e1111111-1111-1111-1111-111111111111','e4444444-4444-4444-4444-444444444444','e5555555-5555-5555-5555-555555555555'], NOW(), NOW() ), ('99999999-9999-9999-9999-999999999999', 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', 'Isaac', ARRAY['Independent','Documentary'], ARRAY['tt0114369','tt0120737'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e2222222-2222-2222-2222-222222222222','e4444444-4444-4444-4444-444444444444'], + ARRAY['e4444444-4444-4444-4444-444444444444','e5555555-5555-5555-5555-555555555555'], NOW(), NOW() ), ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', 'Julia', ARRAY['Drama','Romance'], ARRAY['tt0133093','tt0088763'], false, false, NULL, - ARRAY[]::text[], ARRAY[]::text[], + ARRAY['e2222222-2222-2222-2222-222222222222','e4444444-4444-4444-4444-444444444444'], + ARRAY['e4444444-4444-4444-4444-444444444444'], NOW(), NOW() ) ON CONFLICT ("userId") DO NOTHING; diff --git a/backend/src/controllers/post.ts b/backend/src/controllers/post.ts index af93761..6c4ed2b 100644 --- a/backend/src/controllers/post.ts +++ b/backend/src/controllers/post.ts @@ -134,7 +134,7 @@ export const getPostById = async (req: Request, res: Response) => { }, }, PostReaction: true, - Reposts: { + other_Post: { include: { UserProfile: { select: { @@ -158,9 +158,10 @@ export const getPostById = async (req: Request, res: Response) => { message: "Post found successfully", data: { ...post, + Reposts: post.other_Post, reactionCount: post.PostReaction.length, commentCount: post.Comment.length, - repostCount: post.Reposts.length, + repostCount: post.other_Post.length, }, }); } catch (err) { @@ -238,7 +239,7 @@ export const getPosts = async (req: Request, res: Response) => { id: true, }, }, - Reposts: { + other_Post: { select: { id: true, }, @@ -260,11 +261,12 @@ export const getPosts = async (req: Request, res: Response) => { return { ...post, + Reposts: post.other_Post, reactionCount: post.PostReaction.length, reactionCounts, userReactions: userReactionsByPost.get(post.id) || [], commentCount: post.Comment.length, - repostCount: post.Reposts.length, + repostCount: post.other_Post.length, }; }); @@ -521,7 +523,7 @@ export const getPostReposts = async (req: Request, res: Response) => { }, }, PostReaction: true, - Reposts: { + other_Post: { select: { id: true, }, @@ -534,8 +536,9 @@ export const getPostReposts = async (req: Request, res: Response) => { const repostsWithCounts = reposts.map((repost) => ({ ...repost, + Reposts: repost.other_Post, reactionCount: repost.PostReaction?.length || 0, - repostCount: repost.Reposts.length, + repostCount: repost.other_Post.length, })); res.json({ diff --git a/backend/src/controllers/search.ts b/backend/src/controllers/search.ts index 6d17729..a15c12a 100644 --- a/backend/src/controllers/search.ts +++ b/backend/src/controllers/search.ts @@ -220,21 +220,59 @@ export const searchUsers = async (req: Request, res: Response) => { } try { - const users = await prisma.userProfile.findMany({ - where: { + const orClauses: any[] = [ + { username: { contains: q, - mode: "insensitive" - } + mode: "insensitive", + }, + }, + ]; + + // If the query looks like a UUID, also match on userId directly (no ILIKE on UUID) + const isUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + if (isUuid.test(q)) { + orClauses.push({ userId: q }); + } + + const users = await prisma.userProfile.findMany({ + where: { + OR: orClauses, }, take: limitNum, }); + const toStrings = (val?: string[] | null) => + Array.isArray(val) ? (val as string[]) : []; + + const normalized = users.map((u) => ({ + userId: u.userId, + username: u.username, + onboardingCompleted: u.onboardingCompleted, + primaryLanguage: u.primaryLanguage, + secondaryLanguage: toStrings(u.secondaryLanguage), + profilePicture: u.profilePicture, + country: u.country, + city: u.city, + displayName: u.displayName, + favoriteGenres: toStrings(u.favoriteGenres), + favoriteMovies: toStrings(u.favoriteMovies), + bio: u.bio, + moviesToWatch: toStrings(u.moviesToWatch), + moviesCompleted: toStrings(u.moviesCompleted), + eventsSaved: toStrings(u.eventsSaved), + eventsAttended: toStrings(u.eventsAttended), + privateAccount: u.privateAccount, + spoiler: u.spoiler, + createdAt: u.createdAt, + updatedAt: u.updatedAt, + })); + return res.json({ type: "users", query: q, - count: users.length, - results: users, + count: normalized.length, + results: normalized, }); } catch (error) { console.error("searchUsers error:", error); diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 337c511..e05dabe 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -238,6 +238,63 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = } }; +// Public profile lookup by userId (used when viewing another user's profile) +export const getUserProfileById = async (req: Request, res: Response) => { + const { userId } = req.params; + if (!userId) { + return res.status(400).json({ message: "userId is required" }); + } + + try { + const userProfile = await prisma.userProfile.findUnique({ + where: { userId }, + }); + + if (!userProfile) { + return res.status(404).json({ message: "User profile not found" }); + } + + const mappedUserProfile = mapUserProfileDbToApi({ + userId: userProfile.userId, + username: userProfile.username, + onboardingCompleted: userProfile.onboardingCompleted, + primaryLanguage: userProfile.primaryLanguage, + secondaryLanguage: Array.isArray(userProfile.secondaryLanguage) + ? (userProfile.secondaryLanguage as string[]) + : [], + profilePicture: userProfile.profilePicture, + country: userProfile.country, + city: userProfile.city, + displayName: userProfile.displayName, + favoriteGenres: Array.isArray(userProfile.favoriteGenres) + ? (userProfile.favoriteGenres as string[]) + : [], + favoriteMovies: Array.isArray(userProfile.favoriteMovies) + ? (userProfile.favoriteMovies as string[]) + : [], + bio: userProfile.bio ?? null, + eventsSaved: Array.isArray(userProfile.eventsSaved) + ? (userProfile.eventsSaved as string[]) + : [], + eventsAttended: Array.isArray(userProfile.eventsAttended) + ? (userProfile.eventsAttended as string[]) + : [], + privateAccount: Boolean(userProfile.privateAccount), + spoiler: Boolean(userProfile.spoiler), + createdAt: userProfile.createdAt, + updatedAt: userProfile.updatedAt, + }); + + return res.json({ + message: "User profile retrieved successfully", + userProfile: mappedUserProfile, + }); + } catch (error) { + console.error("getUserProfileById error:", error); + return res.status(500).json({ message: "Failed to retrieve user profile" }); + } +}; + export const getUserRatings = async (req: Request, res: Response): Promise => { const { user_id } = req.query; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index cea9573..1be9b8d 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -6,7 +6,7 @@ import { getMovieById, updateMovie, } from "../controllers/tmdb"; -import { deleteUserProfile, ensureUserProfile, getUserComments, getUserProfile, getUserRatings, updateUserProfile } from '../controllers/user'; +import { deleteUserProfile, ensureUserProfile, getUserComments, getUserProfile, getUserProfileById, getUserRatings, updateUserProfile } from '../controllers/user'; import { authenticateUser } from '../middleware/auth'; import { protect } from "../controllers/protected"; import { getLocalEvent, createLocalEvent, updateLocalEvent, deleteLocalEvent, getLocalEvents } from "../controllers/local-events" @@ -44,6 +44,7 @@ router.get('/api/protected', protect); // User Profile Routes router.get('/api/user/profile', getUserProfile); +router.get('/api/user/profile/:userId', getUserProfileById); router.put("/api/user/profile", updateUserProfile); router.delete("/api/user/profile", deleteUserProfile); diff --git a/frontend/app/profilePage/components/EventsList.tsx b/frontend/app/profilePage/components/EventsList.tsx index 9447e6d..871076d 100644 --- a/frontend/app/profilePage/components/EventsList.tsx +++ b/frontend/app/profilePage/components/EventsList.tsx @@ -17,9 +17,12 @@ type Props = { eventsAttended?: string[] | null; }; -const EventsList = ({ userId, eventsSaved = [], eventsAttended = [] }: Props) => { - const savedIds = useMemo(() => eventsSaved ?? [], [eventsSaved]); - const attendedIds = useMemo(() => eventsAttended ?? [], [eventsAttended]); +const EMPTY_IDS: string[] = []; + +const EventsList = ({ userId, eventsSaved, eventsAttended }: Props) => { + // Normalize to stable references to avoid re-running effects on every render + const savedIds = useMemo(() => eventsSaved ?? EMPTY_IDS, [eventsSaved]); + const attendedIds = useMemo(() => eventsAttended ?? EMPTY_IDS, [eventsAttended]); const [activeSubTab, setActiveSubTab] = useState<'saved' | 'attended'>( 'saved' ); diff --git a/frontend/app/profilePage/index.tsx b/frontend/app/profilePage/index.tsx index fff05db..bd84cf1 100644 --- a/frontend/app/profilePage/index.tsx +++ b/frontend/app/profilePage/index.tsx @@ -39,6 +39,7 @@ type Props = { onUnfollow?: () => Promise | void; isFollowing?: boolean; profileUserId?: string; + profileData?: UserProfile | null; }; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -60,9 +61,10 @@ const ProfilePage = ({ onUnfollow, isFollowing = false, profileUserId, + profileData = null, }: Props) => { const [activeTab, setActiveTab] = useState('movies'); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(profileData); const [followersCount, setFollowersCount] = useState(0); const [followingCount, setFollowingCount] = useState(0); const [loading, setLoading] = useState(isMe); @@ -137,6 +139,12 @@ const ProfilePage = ({ }; }, [fetchProfileData, isMe]); + useEffect(() => { + if (profileData) { + setProfile(profileData); + } + }, [profileData]); + const resolvedDisplayName = isMe ? profile?.displayName?.trim() || profile?.username?.trim() || diff --git a/frontend/app/profilePage/user/[userId].tsx b/frontend/app/profilePage/user/[userId].tsx index 6fa8825..7c6db4a 100644 --- a/frontend/app/profilePage/user/[userId].tsx +++ b/frontend/app/profilePage/user/[userId].tsx @@ -3,9 +3,10 @@ import { useLocalSearchParams } from 'expo-router'; import { DeviceEventEmitter } from 'react-native'; import ProfilePage from '../index'; import { followUser, unfollowUser, getFollowers, getFollowing } from '../../../lib/profilePage/followServiceProxy'; -import { getUserProfile } from '../../../services/userService'; +import { getUserProfile, getUserProfileById } from '../../../services/userService'; import { searchUsers } from '../../../services/searchService'; import type { User } from '../../../lib/profilePage/_types'; +import type { components } from '../../../types/api-generated'; /** * Standalone profile screen for viewing another user's page. @@ -28,6 +29,7 @@ export default function OtherUserProfile() { const [currentUserId, setCurrentUserId] = useState(null); const initialUserId = params.userId ?? 'demo-user'; const [resolvedUserId, setResolvedUserId] = useState(initialUserId); + const [profileData, setProfileData] = useState(null); const isValidUuid = (val: string | null | undefined) => !!val && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( @@ -39,10 +41,6 @@ export default function OtherUserProfile() { useEffect(() => { const maybeResolve = async () => { - if (isValidUuid(initialUserId)) { - setResolvedUserId(initialUserId); - return; - } const query = params.username || params.userId || params.name; if (!query) return; try { @@ -51,8 +49,34 @@ export default function OtherUserProfile() { const match = results.find((u) => (u.username || '').toLowerCase() === normalized) || results[0]; - if (match?.userId && isValidUuid(match.userId)) { - setResolvedUserId(match.userId); + if (match?.userId) { + if (isValidUuid(match.userId)) { + setResolvedUserId(match.userId); + } + // Capture profile data (events, favorites, etc.) when available + const safeProfile: components['schemas']['UserProfile'] = { + userId: match.userId, + username: match.username ?? null, + onboardingCompleted: Boolean(match.onboardingCompleted), + primaryLanguage: match.primaryLanguage ?? 'English', + secondaryLanguage: match.secondaryLanguage ?? [], + profilePicture: match.profilePicture ?? null, + country: match.country ?? null, + city: match.city ?? null, + displayName: match.displayName ?? match.username ?? null, + favoriteGenres: match.favoriteGenres ?? [], + favoriteMovies: match.favoriteMovies ?? [], + bio: match.bio ?? null, + moviesToWatch: match.moviesToWatch ?? [], + moviesCompleted: match.moviesCompleted ?? [], + eventsSaved: match.eventsSaved ?? [], + eventsAttended: match.eventsAttended ?? [], + privateAccount: Boolean(match.privateAccount), + spoiler: Boolean(match.spoiler), + createdAt: match.createdAt ?? new Date().toISOString(), + updatedAt: match.updatedAt ?? new Date().toISOString(), + }; + setProfileData(safeProfile); } } catch (err) { console.error('Failed to resolve userId from username search:', err); @@ -61,6 +85,22 @@ export default function OtherUserProfile() { maybeResolve(); }, [initialUserId, params.name, params.userId, params.username]); + // Once we know the userId, fetch the full profile (including events) directly + useEffect(() => { + const fetchProfile = async () => { + if (!isValidUuid(resolvedUserId)) return; + try { + const res = await getUserProfileById(resolvedUserId); + if (res?.userProfile) { + setProfileData(res.userProfile as components['schemas']['UserProfile']); + } + } catch (err) { + console.error('Failed to fetch profile by id:', err); + } + }; + fetchProfile(); + }, [resolvedUserId]); + const loadCounts = useCallback(async () => { if (!isValidUuid(resolvedUserId)) { setFollowersCount(0); @@ -153,6 +193,7 @@ export default function OtherUserProfile() { onFollow={isValidUuid(resolvedUserId) ? handleFollow : undefined} onUnfollow={isValidUuid(resolvedUserId) ? handleUnfollow : undefined} profileUserId={resolvedUserId} + profileData={profileData} /> ); } diff --git a/frontend/services/searchService.ts b/frontend/services/searchService.ts index 89d9e7b..eaa7948 100644 --- a/frontend/services/searchService.ts +++ b/frontend/services/searchService.ts @@ -5,10 +5,28 @@ type SearchUser = { username?: string; name?: string; profilePicture?: string; + onboardingCompleted?: boolean; + primaryLanguage?: string; + secondaryLanguage?: string[]; + country?: string | null; + city?: string | null; + displayName?: string | null; + favoriteGenres?: string[]; + favoriteMovies?: string[]; + bio?: string | null; + moviesToWatch?: string[]; + moviesCompleted?: string[]; + privateAccount?: boolean; + spoiler?: boolean; + createdAt?: string; + updatedAt?: string; + eventsSaved?: string[]; + eventsAttended?: string[]; }; type SearchUsersResponse = { data?: SearchUser[]; + results?: SearchUser[]; message?: string; }; @@ -17,5 +35,6 @@ export async function searchUsers(query: string, limit: number = 10): Promise(`/api/user/profile`); } +export function getUserProfileById(userId: string) { + return api.get(`/api/user/profile/${userId}`); +} + export async function getUserProfileBasic() { const res = await getUserProfile(); const profile = res.userProfile; From 5357f0cca97f043d7d967c92aa0e3cd003851143 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:51:53 -0500 Subject: [PATCH 09/12] Bio, displayname, events saved + attended for user controller --- backend/src/controllers/user.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 15d7be4..a2dfd3e 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -17,6 +17,7 @@ export const updateUserProfile = async (req, res) => { const normalized = { username: body.username ?? null, + displayName: body.displayName ?? null, onboardingCompleted: body.onboardingCompleted, primaryLanguage: body.primaryLanguage, secondaryLanguage: Array.isArray(body.secondaryLanguage) @@ -31,6 +32,13 @@ export const updateUserProfile = async (req, res) => { favoriteMovies: Array.isArray(body.favoriteMovies) ? body.favoriteMovies : [], + bio: body.bio ?? null, + eventsSaved: Array.isArray(body.eventsSaved) + ? Array.from(new Set(body.eventsSaved)) + : undefined, + eventsAttended: Array.isArray(body.eventsAttended) + ? Array.from(new Set(body.eventsAttended)) + : undefined, privateAccount: typeof body.privateAccount === 'boolean' ? body.privateAccount @@ -57,6 +65,9 @@ export const updateUserProfile = async (req, res) => { ...(normalized.username !== undefined && { username: normalized.username, }), + ...(normalized.displayName !== undefined) && { + displayName: normalized.displayName, + }, ...(normalized.onboardingCompleted !== undefined && { onboardingCompleted: normalized.onboardingCompleted, }), @@ -77,6 +88,15 @@ export const updateUserProfile = async (req, res) => { ...(normalized.favoriteMovies !== undefined && { favoriteMovies: normalized.favoriteMovies, }), + ...(normalized.bio !== undefined && { + bio: normalized.bio, + }), + ...(normalized.eventsSaved !== undefined && { + eventsSaved: normalized.eventsSaved, + }), + ...(normalized.eventsAttended !== undefined && { + eventsAttended: normalized.eventsAttended, + }), ...(normalized.privateAccount !== undefined && { privateAccount: normalized.privateAccount, }), From dd9ea950dc2a64a3dda809e2033555df700aa9e1 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:12:57 -0500 Subject: [PATCH 10/12] Fix failing tests --- backend/src/controllers/event-rsvp.ts | 3 + backend/src/tests/unit/post.unit.test.ts | 25 +- backend/src/tests/unit/search.unit.test.ts | 26 +- frontend/types/api-generated.ts | 265 ++++++++++++++------- 4 files changed, 227 insertions(+), 92 deletions(-) diff --git a/backend/src/controllers/event-rsvp.ts b/backend/src/controllers/event-rsvp.ts index 0c7f328..ecf5ec5 100644 --- a/backend/src/controllers/event-rsvp.ts +++ b/backend/src/controllers/event-rsvp.ts @@ -1,6 +1,7 @@ import type { Response } from "express"; import { AuthenticatedRequest } from "../middleware/auth.js"; import { prisma } from "../services/db.js"; +import { randomUUID } from "crypto"; /** * Create or update an RSVP for an event @@ -42,9 +43,11 @@ export const createOrUpdateRsvp = async (req: AuthenticatedRequest, res: Respons updatedAt: new Date(), }, create: { + id: randomUUID(), eventId, userId, status, + updatedAt: new Date(), }, include: { UserProfile: { diff --git a/backend/src/tests/unit/post.unit.test.ts b/backend/src/tests/unit/post.unit.test.ts index 0865036..c2fa289 100644 --- a/backend/src/tests/unit/post.unit.test.ts +++ b/backend/src/tests/unit/post.unit.test.ts @@ -154,7 +154,7 @@ describe("Post Controller Unit Tests", () => { title: "Test Movie", imageUrl: null, }, - Reposts: [{ id: "repost-1" }, { id: "repost-2" }], + other_Post: [{ id: "repost-1" }, { id: "repost-2" }], PostReaction: [ { id: "reaction-1", reactionType: "SPICY" }, { id: "reaction-2", reactionType: "BLOCKBUSTER" }, @@ -169,12 +169,29 @@ describe("Post Controller Unit Tests", () => { expect(responseObject.json).toHaveBeenCalledWith({ message: "Post found successfully", - data: { - ...mockPost, + data: expect.objectContaining({ + id: mockPostId, + userId: "user-123", + content: "Test post", + type: "SHORT", + UserProfile: expect.objectContaining({ + userId: "user-123", + username: "testuser", + }), + Comment: [], + PostReaction: expect.arrayContaining([ + expect.objectContaining({ reactionType: "SPICY" }), + expect.objectContaining({ reactionType: "BLOCKBUSTER" }), + expect.objectContaining({ reactionType: "STAR_STUDDED" }), + ]), + Reposts: expect.arrayContaining([ + expect.objectContaining({ id: "repost-1" }), + expect.objectContaining({ id: "repost-2" }), + ]), reactionCount: 3, commentCount: 0, repostCount: 2, - }, + }), }); }); }); diff --git a/backend/src/tests/unit/search.unit.test.ts b/backend/src/tests/unit/search.unit.test.ts index 918e744..7d40c95 100644 --- a/backend/src/tests/unit/search.unit.test.ts +++ b/backend/src/tests/unit/search.unit.test.ts @@ -174,10 +174,24 @@ describe("Search Controller Unit Tests", () => { { userId: "user-uuid", username: "john_doe", - preferredCategories: ["Action"], - preferredLanguages: ["English"], + onboardingCompleted: null, + primaryLanguage: null, + secondaryLanguage: [], + profilePicture: null, + country: null, + city: null, + displayName: null, + favoriteGenres: [], favoriteMovies: [], + bio: null, + moviesToWatch: [], + moviesCompleted: [], + eventsSaved: [], + eventsAttended: [], + privateAccount: null, + spoiler: null, createdAt: new Date(), + updatedAt: null, }, ]; @@ -191,7 +205,13 @@ describe("Search Controller Unit Tests", () => { type: "users", query: "john", count: 1, - results: mockUsers, + results: expect.arrayContaining([ + expect.objectContaining({ + userId: "user-uuid", + username: "john_doe", + favoriteMovies: [], + }) + ]), }) ); }); diff --git a/frontend/types/api-generated.ts b/frontend/types/api-generated.ts index 4c105a9..cd8d68c 100644 --- a/frontend/types/api-generated.ts +++ b/frontend/types/api-generated.ts @@ -409,6 +409,8 @@ export interface paths { /** @example any */ username?: unknown; /** @example any */ + displayName?: unknown; + /** @example any */ onboardingCompleted?: unknown; /** @example any */ primaryLanguage?: unknown; @@ -421,29 +423,23 @@ export interface paths { /** @example any */ city?: unknown; /** @example any */ - displayName?: unknown; - /** @example any */ favoriteGenres?: unknown; /** @example any */ favoriteMovies?: unknown; /** @example any */ - updatedAt?: unknown; - bookmarkedToWatch?: unknown; - bookmarkedWatched?: unknown; - /** @example any */ - privateAccount?: unknown; + bio?: unknown; /** @example any */ - spoiler?: unknown; + eventsSaved?: unknown; /** @example any */ - bio?: unknown; + eventsAttended?: unknown; /** @example any */ - moviesToWatch?: unknown; + privateAccount?: unknown; /** @example any */ - moviesCompleted?: unknown; + spoiler?: unknown; /** @example any */ - eventsSaved?: unknown; + bookmarkedToWatch?: unknown; /** @example any */ - eventsAttended?: unknown; + bookmarkedWatched?: unknown; }; }; }; @@ -462,8 +458,36 @@ export interface paths { }; content?: never; }; - /** @description Not Found */ - 404: { + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + post?: never; + delete: { + parameters: { + query?: never; + header?: { + authorization?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -478,14 +502,27 @@ export interface paths { }; }; }; - post?: never; - delete: { + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/user/profile/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { parameters: { query?: never; header?: { authorization?: string; }; - path?: never; + path: { + userId: string; + }; cookie?: never; }; requestBody?: never; @@ -497,6 +534,13 @@ export interface paths { }; content?: never; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Unauthorized */ 401: { headers: { @@ -504,6 +548,13 @@ export interface paths { }; content?: never; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Internal Server Error */ 500: { headers: { @@ -513,6 +564,9 @@ export interface paths { }; }; }; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; @@ -834,7 +888,9 @@ export interface paths { }; get: { parameters: { - query?: never; + query?: { + user_id?: string; + }; header?: { authorization?: string; }; @@ -843,6 +899,20 @@ export interface paths { }; requestBody?: never; responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Unauthorized */ 401: { headers: { @@ -1052,6 +1122,95 @@ export interface paths { patch?: never; trace?: never; }; + "/movies/after/{year}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + year: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/movies/random/10": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/feed": { parameters: { query?: never; @@ -1127,8 +1286,6 @@ export interface paths { /** @example any */ content?: unknown; /** @example any */ - ratingId?: unknown; - /** @example any */ postId?: unknown; /** @example any */ parentId?: unknown; @@ -1604,64 +1761,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/comments/rating/{ratingId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: { - authorization?: string; - }; - path: { - ratingId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/ratings": { parameters: { query?: never; @@ -3646,8 +3745,6 @@ export interface components { favoriteGenres: string[]; favoriteMovies: string[]; bio?: string | null; - moviesToWatch: string[]; - moviesCompleted: string[]; eventsSaved: string[]; eventsAttended: string[]; privateAccount: boolean; @@ -3670,13 +3767,11 @@ export interface components { displayName?: string | null; favoriteGenres?: string[]; favoriteMovies?: string[]; - bookmarkedToWatch?: string[]; - bookmarkedWatched?: string[]; bio?: string | null; privateAccount?: boolean; spoiler?: boolean; - moviesToWatch?: string[]; - moviesCompleted?: string[]; + bookmarkedToWatch?: string[]; + bookmarkedWatched?: string[]; eventsSaved?: string[]; eventsAttended?: string[]; }; From 16564f819bd8bb75e768ce311a10f61b47b40b7e Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:16:30 -0500 Subject: [PATCH 11/12] Fix: frontend types --- .../app/profilePage/components/MoviesGrid.tsx | 8 ++ frontend/app/profilePage/user/[userId].tsx | 80 ++++++++++++------- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/frontend/app/profilePage/components/MoviesGrid.tsx b/frontend/app/profilePage/components/MoviesGrid.tsx index 559933e..3e8a9a6 100644 --- a/frontend/app/profilePage/components/MoviesGrid.tsx +++ b/frontend/app/profilePage/components/MoviesGrid.tsx @@ -121,6 +121,14 @@ const MoviesGrid = (props: Props | undefined) => { } }, []); + const hydrateFromProfile = useCallback(async () => { + try { + await fetchMoviesForUser(); + } catch (error) { + console.error('Error hydrating profile:', error); + } + }, [fetchMoviesForUser]); + useEffect(() => { hydrateFromProfile(); }, [hydrateFromProfile]); diff --git a/frontend/app/profilePage/user/[userId].tsx b/frontend/app/profilePage/user/[userId].tsx index 7c6db4a..6b23e15 100644 --- a/frontend/app/profilePage/user/[userId].tsx +++ b/frontend/app/profilePage/user/[userId].tsx @@ -40,49 +40,67 @@ export default function OtherUserProfile() { params.username?.trim() || params.userId || params.name || 'user'; useEffect(() => { - const maybeResolve = async () => { + const fetchUserProfile = async () => { const query = params.username || params.userId || params.name; if (!query) return; + try { + // First, try to find the user by ID if we have a valid UUID + if (isValidUuid(query)) { + const response = await getUserProfileById(query); + if (response?.userProfile) { + setResolvedUserId(response.userProfile.userId); + setProfileData(response.userProfile); + return; + } + } + + // If no user found by ID or not a valid UUID, try searching by username const results = await searchUsers(String(query), 5); const normalized = String(query).toLowerCase(); - const match = - results.find((u) => (u.username || '').toLowerCase() === normalized) || - results[0]; + const match = results.find((u) => + (u.username || '').toLowerCase() === normalized || + u.userId === query + ) || results[0]; + if (match?.userId) { - if (isValidUuid(match.userId)) { + // Now fetch the full profile using the user ID + const response = await getUserProfileById(match.userId); + if (response?.userProfile) { + setResolvedUserId(response.userProfile.userId); + setProfileData(response.userProfile); + } else { + // Fallback to basic info if full profile fetch fails setResolvedUserId(match.userId); + setProfileData({ + userId: match.userId, + username: match.username || '', + onboardingCompleted: false, + primaryLanguage: 'English', + secondaryLanguage: [], + profilePicture: match.profilePicture || null, + country: null, + city: null, + displayName: match.displayName || match.username || null, + favoriteGenres: [], + favoriteMovies: [], + bio: match.bio || null, + eventsSaved: [], + eventsAttended: [], + privateAccount: false, + spoiler: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + bookmarkedToWatch: [], + bookmarkedWatched: [] + }); } - // Capture profile data (events, favorites, etc.) when available - const safeProfile: components['schemas']['UserProfile'] = { - userId: match.userId, - username: match.username ?? null, - onboardingCompleted: Boolean(match.onboardingCompleted), - primaryLanguage: match.primaryLanguage ?? 'English', - secondaryLanguage: match.secondaryLanguage ?? [], - profilePicture: match.profilePicture ?? null, - country: match.country ?? null, - city: match.city ?? null, - displayName: match.displayName ?? match.username ?? null, - favoriteGenres: match.favoriteGenres ?? [], - favoriteMovies: match.favoriteMovies ?? [], - bio: match.bio ?? null, - moviesToWatch: match.moviesToWatch ?? [], - moviesCompleted: match.moviesCompleted ?? [], - eventsSaved: match.eventsSaved ?? [], - eventsAttended: match.eventsAttended ?? [], - privateAccount: Boolean(match.privateAccount), - spoiler: Boolean(match.spoiler), - createdAt: match.createdAt ?? new Date().toISOString(), - updatedAt: match.updatedAt ?? new Date().toISOString(), - }; - setProfileData(safeProfile); - } + } } catch (err) { console.error('Failed to resolve userId from username search:', err); } }; - maybeResolve(); + fetchUserProfile(); }, [initialUserId, params.name, params.userId, params.username]); // Once we know the userId, fetch the full profile (including events) directly From fe6576453ff84a6b67ddefe6bb9b1790b17ff829 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:31:20 -0500 Subject: [PATCH 12/12] Bookmark events --- frontend/app/events/eventDetail.tsx | 80 ++++++++++++++++--- .../app/profilePage/components/EventsList.tsx | 8 +- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/frontend/app/events/eventDetail.tsx b/frontend/app/events/eventDetail.tsx index f109abd..f2d3c55 100644 --- a/frontend/app/events/eventDetail.tsx +++ b/frontend/app/events/eventDetail.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; import { StyleSheet, View, @@ -8,17 +9,20 @@ import { ActivityIndicator, Modal, Image, + Alert, } from 'react-native'; import RsvpNotification from '../../components/RsvpNotification'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import { CircleDollarSign, MapPin, Calendar } from 'lucide-react-native'; +import { CircleDollarSign, MapPin, Calendar, Bookmark, BookmarkCheck } from 'lucide-react-native'; import Entypo from '@expo/vector-icons/Entypo'; import Rsvp from '../../components/Rsvp'; import { router, useLocalSearchParams } from 'expo-router'; import { getLocalEvent, type LocalEvent } from '../../services/eventsService'; import { createOrUpdateRsvp, getUserRsvp } from '../../services/rsvpService'; +import { getUserProfile, updateUserProfile } from '../../services/userService'; import LocationSection from '../../components/LocationSection'; +import { useAuth } from '../../context/AuthContext'; export default function EventDetailScreen() { const { eventId } = useLocalSearchParams<{ eventId: string }>(); @@ -29,13 +33,43 @@ export default function EventDetailScreen() { const [showRsvpModal, setShowRsvpModal] = useState(false); const [userRsvp, setUserRsvp] = useState<'yes' | 'maybe' | 'no' | null>(null); const [showNotification, setShowNotification] = useState(false); + const [isBookmarked, setIsBookmarked] = useState(false); + const [savedEvents, setSavedEvents] = useState([]); + const { user } = useAuth(); + + // Load event details, user RSVP, and saved events + const loadUserData = useCallback(async () => { + if (!user?.id) return; + + try { + const profile = await getUserProfile(); + if (profile?.userProfile) { + const { eventsSaved = [] } = profile.userProfile; + setSavedEvents(eventsSaved); + setIsBookmarked(eventId ? eventsSaved.includes(eventId) : false); + } + } catch (error) { + console.error('Failed to load user profile:', error); + } + }, [user, eventId]); + // Load data when component mounts or eventId changes useEffect(() => { if (eventId) { loadEventDetails(); loadUserRsvp(); + loadUserData(); } - }, [eventId]); + }, [eventId, loadUserData]); + + // Refresh data when screen comes into focus + useFocusEffect( + useCallback(() => { + if (eventId) { + loadUserData(); + } + }, [eventId, loadUserData]) + ); const loadEventDetails = async () => { if (!eventId) return; @@ -98,6 +132,31 @@ export default function EventDetailScreen() { setShowNotification(false); }; + const toggleBookmark = async () => { + if (!user?.id || !eventId) return; + + try { + const updatedSavedEvents = isBookmarked + ? savedEvents.filter(id => id !== eventId) + : [...savedEvents, eventId]; + + // Optimistic UI update + setIsBookmarked(!isBookmarked); + setSavedEvents(updatedSavedEvents); + + // Update the backend + await updateUserProfile({ + eventsSaved: updatedSavedEvents + }); + + } catch (error) { + // Revert on error + setIsBookmarked(!isBookmarked); + console.error('Failed to update bookmarks:', error); + Alert.alert('Error', 'Failed to update bookmarks. Please try again.'); + } + }; + if (loading) { return ( @@ -159,12 +218,15 @@ export default function EventDetailScreen() { - - + + {isBookmarked ? ( + + ) : ( + + )} diff --git a/frontend/app/profilePage/components/EventsList.tsx b/frontend/app/profilePage/components/EventsList.tsx index 871076d..c3b391f 100644 --- a/frontend/app/profilePage/components/EventsList.tsx +++ b/frontend/app/profilePage/components/EventsList.tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, FlatList, } from 'react-native'; +import { useRouter } from 'expo-router'; import tw from 'twrnc'; import UpcomingEventCard from '../../events/components/UpcomingEventCard'; import type { LocalEvent } from '../../../services/eventsService'; @@ -20,6 +21,7 @@ type Props = { const EMPTY_IDS: string[] = []; const EventsList = ({ userId, eventsSaved, eventsAttended }: Props) => { + const router = useRouter(); // Normalize to stable references to avoid re-running effects on every render const savedIds = useMemo(() => eventsSaved ?? EMPTY_IDS, [eventsSaved]); const attendedIds = useMemo(() => eventsAttended ?? EMPTY_IDS, [eventsAttended]); @@ -158,7 +160,11 @@ const EventsList = ({ userId, eventsSaved, eventsAttended }: Props) => { showsVerticalScrollIndicator={false} renderItem={({ item }) => ( - + router.push(`/events/eventDetail?eventId=${item.id}`)} + /> )} ListFooterComponent={}