diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f2b55e1..06dda68 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") } @@ -500,12 +498,18 @@ 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? + 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[] @@ -528,8 +532,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 @@ -545,22 +563,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? @@ -570,7 +574,7 @@ model movie { imageUrl String? releaseYear Int? director String? - Post Post[] // All movie posts (SHORT and LONG) + Post Post[] @@schema("public") } @@ -662,10 +666,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 223178f..d510b46 100644 --- a/backend/prisma/seed.sql +++ b/backend/prisma/seed.sql @@ -42,8 +42,14 @@ INSERT INTO "public"."UserProfile" ( "profilePicture", "country", "city", + "displayName", "favoriteGenres", "favoriteMovies", + "privateAccount", + "spoiler", + "bio", + "eventsSaved", + "eventsAttended", "createdAt", "updatedAt", "bookmarkedToWatch", @@ -61,16 +67,86 @@ INSERT INTO "public"."UserProfile" ( ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', ARRAY['Drama', 'Romance'], ARRAY['tt0133093', 'tt0088763'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0133093', 'tt0088763']) "spoiler" ) 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(), false), - ('22222222-2222-2222-2222-222222222222', 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', ARRAY['Action', 'Sci-Fi'], ARRAY['tt0468569', 'tt0137523'], NOW(), NOW(), false), - ('33333333-3333-3333-3333-333333333333', 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', ARRAY['Comedy', 'Romance'], ARRAY['tt0109830', 'tt1375666'], NOW(), NOW(), false), - ('44444444-4444-4444-4444-444444444444', 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', ARRAY['Drama', 'Biography'], ARRAY['tt0073486', 'tt0099685'], NOW(), NOW(), false), - ('55555555-5555-5555-5555-555555555555', 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', ARRAY['Animation', 'Fantasy'], ARRAY['tt0245429', 'tt1853728'], NOW(), NOW(), false), - ('66666666-6666-6666-6666-666666666666', 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', ARRAY['Horror', 'Mystery'], ARRAY['tt0816692', 'tt0110912'], NOW(), NOW(), false), - ('77777777-7777-7777-7777-777777777777', 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', ARRAY['Western', 'Crime'], ARRAY['tt0076759', 'tt0050083'], NOW(), NOW(), false), - ('88888888-8888-8888-8888-888888888888', 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', ARRAY['Drama', 'Thriller'], ARRAY['tt6751668', 'tt0167260'], NOW(), NOW(), false), - ('99999999-9999-9999-9999-999999999999', 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', ARRAY['Independent', 'Documentary'], ARRAY['tt0114369', 'tt0120737'], NOW(), NOW(), false), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', ARRAY['Drama', 'Romance'], ARRAY['tt0133093', 'tt0088763'], NOW(), NOW(), false) + ('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['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['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['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['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['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['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['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['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['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['e2222222-2222-2222-2222-222222222222','e4444444-4444-4444-4444-444444444444'], + ARRAY['e4444444-4444-4444-4444-444444444444'], + NOW(), NOW() + ) ON CONFLICT ("userId") DO NOTHING; -- ============================================ @@ -746,4 +822,3 @@ ON CONFLICT ("id") DO NOTHING; -- Success Message -- ============================================ SELECT 'Seed data inserted successfully!' as message; - 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/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 f57ba1c..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, }), @@ -150,11 +170,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, + eventsSaved: [], + eventsAttended: [], updatedAt: new Date(), bookmarkedToWatch: [], bookmarkedWatched: [], @@ -212,23 +236,31 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = profilePicture: userProfile.profilePicture, country: userProfile.country, city: userProfile.city, - favoriteGenres: Array.isArray(userProfile.favoriteGenres) - ? userProfile.favoriteGenres as string[] - : [], - favoriteMovies: Array.isArray(userProfile.favoriteMovies) - ? userProfile.favoriteMovies 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, + 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, bookmarkedToWatch: Array.isArray(userProfile.bookmarkedToWatch) ? userProfile.bookmarkedToWatch as string[] : [], bookmarkedWatched: Array.isArray(userProfile.bookmarkedWatched) ? userProfile.bookmarkedWatched as string[] : [], - }); + }); const basicUser = req.user ? { @@ -269,6 +301,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; @@ -299,15 +388,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, + 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, @@ -363,14 +460,21 @@ export const getUserComments = async (req: Request, res: Response): Promise> ): Prisma.UserProfileUpdateInput { const data: Prisma.UserProfileUpdateInput = {}; @@ -453,6 +565,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; } @@ -477,11 +592,31 @@ 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, "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; } - 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; + } + if (Object.prototype.hasOwnProperty.call(patch, "bookmarkedToWatch")) { + data.bookmarkedToWatch = patch.bookmarkedToWatch ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "bookmarkedWatched")) { + data.bookmarkedWatched = patch.bookmarkedWatched ?? []; } if (Object.prototype.hasOwnProperty.call(patch, "bookmarkedToWatch")) { data.bookmarkedToWatch = patch.bookmarkedToWatch ?? []; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 4b39c1c..1d39523 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/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/backend/src/tests/unit/userProfile.unit.test.ts b/backend/src/tests/unit/userProfile.unit.test.ts new file mode 100644 index 0000000..72a8f25 --- /dev/null +++ b/backend/src/tests/unit/userProfile.unit.test.ts @@ -0,0 +1,51 @@ +import { mapUserProfileDbToApi, mapUserProfilePatchToUpdateData } from '../../controllers/user'; + +describe('User profile mapping', () => { + it('maps DB payload to API shape including 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', + privateAccount: false, + spoiler: false, + createdAt: now, + updatedAt: now, + }); + + expect(api.displayName).toBe('User One'); + expect(api.bio).toBe('Cinephile'); + }); + + 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 event lists when provided in patch', () => { + const data = mapUserProfilePatchToUpdateData({ + displayName: 'Shown Name', + eventsSaved: ['a', 'b'], + eventsAttended: ['c'], + }); + + expect(data).toHaveProperty('displayName', 'Shown Name'); + 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 new file mode 100644 index 0000000..1aa6f07 --- /dev/null +++ b/backend/src/tests/unit/userProfileEvents.test.ts @@ -0,0 +1,58 @@ +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, + 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 e2dd303..8db09d8 100644 --- a/backend/src/types/apiTypes.ts +++ b/backend/src/types/apiTypes.ts @@ -51,12 +51,16 @@ export type UpdateUserProfileInput = { secondaryLanguage?: string[]; country?: string; city?: string; + displayName?: string | null; favoriteGenres?: string[]; favoriteMovies?: string[]; + bio?: string | null; privateAccount?: boolean; spoiler?: boolean; bookmarkedToWatch?: string[]; bookmarkedWatched?: 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 cf3adc8..37c188f 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; + eventsSaved: string[]; + eventsAttended: string[]; privateAccount: boolean; spoiler: boolean; createdAt: Date; @@ -45,11 +49,6 @@ export type Rating = { tags: string[]; date: string; votes: number; - UserProfile?: { - userId: string; - username: string | null; - }; - threadedComments?: unknown[]; }; export type Comment = { @@ -233,4 +232,3 @@ export type ChunkSummary = { stats: SentimentStats; quotes: string[]; }; - 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 792f5f9..c3b391f 100644 --- a/frontend/app/profilePage/components/EventsList.tsx +++ b/frontend/app/profilePage/components/EventsList.tsx @@ -6,31 +6,43 @@ 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'; -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 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]); 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 +50,27 @@ const EventsList = ({ userId }: Props) => { try { setLoading(true); setError(null); - const fetched = await getUserEvents(userId); - setSavedEvents(fetched); + // fetch saved (create arrays of promises) + const fetchSaved: Promise[] = savedIds.length + ? savedIds.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((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'); } finally { setLoading(false); } - }, [userId]); + }, [userId, savedIds, attendedIds]); useEffect(() => { loadEvents(); @@ -127,13 +151,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} @@ -142,7 +160,11 @@ const EventsList = ({ userId }: Props) => { showsVerticalScrollIndicator={false} renderItem={({ item }) => ( - + router.push(`/events/eventDetail?eventId=${item.id}`)} + /> )} ListFooterComponent={} diff --git a/frontend/app/profilePage/components/MoviesGrid.tsx b/frontend/app/profilePage/components/MoviesGrid.tsx index 4d478f7..3e8a9a6 100644 --- a/frontend/app/profilePage/components/MoviesGrid.tsx +++ b/frontend/app/profilePage/components/MoviesGrid.tsx @@ -24,6 +24,8 @@ type MovieListItem = { type Props = { userId?: string | null; + moviesToWatch?: string[] | null; + moviesCompleted?: string[] | null; }; const MoviesGrid = (props: Props | undefined) => { @@ -119,12 +121,19 @@ const MoviesGrid = (props: Props | undefined) => { } }, []); - useEffect(() => { - fetchMoviesForUser(); + const hydrateFromProfile = useCallback(async () => { + try { + await fetchMoviesForUser(); + } catch (error) { + console.error('Error hydrating profile:', error); + } }, [fetchMoviesForUser]); + useEffect(() => { + 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]); @@ -236,7 +245,7 @@ const MoviesGrid = (props: Props | undefined) => { {error} Retry diff --git a/frontend/app/profilePage/index.tsx b/frontend/app/profilePage/index.tsx index b71cb13..bd84cf1 100644 --- a/frontend/app/profilePage/index.tsx +++ b/frontend/app/profilePage/index.tsx @@ -25,7 +25,12 @@ 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[]; + eventsSaved?: string[]; + eventsAttended?: string[]; +}; type Props = { user?: User; @@ -34,6 +39,7 @@ type Props = { onUnfollow?: () => Promise | void; isFollowing?: boolean; profileUserId?: string; + profileData?: UserProfile | null; }; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -55,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); @@ -132,43 +139,51 @@ 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'; + useEffect(() => { + if (profileData) { + setProfile(profileData); + } + }, [profileData]); + + 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 +452,6 @@ const ProfilePage = ({ - {/* Activity header */} - - - - {/* Tabs row */} - {activeTab === 'movies' && } + {activeTab === 'movies' && ( + + )} {activeTab === 'posts' && } - {activeTab === 'events' && } + {activeTab === 'events' && ( + + )} {activeTab === 'badges' && } diff --git a/frontend/app/profilePage/settings.tsx b/frontend/app/profilePage/settings.tsx index 7a9e633..553306d 100644 --- a/frontend/app/profilePage/settings.tsx +++ b/frontend/app/profilePage/settings.tsx @@ -19,8 +19,7 @@ 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 [photoUri, setPhotoUri] = useState('https://i.pravatar.cc/150?img=3'); const [hasCustomPhoto, setHasCustomPhoto] = useState(false); const [loading, setLoading] = useState(true); @@ -38,10 +37,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 +80,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` ); } @@ -245,21 +247,6 @@ export default function Settings() { ]} /> - - {/* WhatsApp Section */} - - WhatsApp Number - - )} diff --git a/frontend/app/profilePage/user/[userId].tsx b/frontend/app/profilePage/user/[userId].tsx index 6fa8825..6b23e15 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( @@ -38,29 +40,85 @@ export default function OtherUserProfile() { params.username?.trim() || params.userId || params.name || 'user'; useEffect(() => { - const maybeResolve = async () => { - if (isValidUuid(initialUserId)) { - setResolvedUserId(initialUserId); - return; - } + 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]; - if (match?.userId && isValidUuid(match.userId)) { - setResolvedUserId(match.userId); - } + const match = results.find((u) => + (u.username || '').toLowerCase() === normalized || + u.userId === query + ) || results[0]; + + if (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: [] + }); + } + } } 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 + 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 +211,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; diff --git a/frontend/types/api-generated.ts b/frontend/types/api-generated.ts index 38da97a..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; @@ -425,13 +427,19 @@ export interface paths { /** @example any */ favoriteMovies?: unknown; /** @example any */ - updatedAt?: unknown; - bookmarkedToWatch?: unknown; - bookmarkedWatched?: unknown; + bio?: unknown; + /** @example any */ + eventsSaved?: unknown; + /** @example any */ + eventsAttended?: unknown; /** @example any */ privateAccount?: unknown; /** @example any */ spoiler?: unknown; + /** @example any */ + bookmarkedToWatch?: unknown; + /** @example any */ + bookmarkedWatched?: unknown; }; }; }; @@ -450,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; }; @@ -466,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; @@ -485,6 +534,13 @@ export interface paths { }; content?: never; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Unauthorized */ 401: { headers: { @@ -492,6 +548,13 @@ export interface paths { }; content?: never; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Internal Server Error */ 500: { headers: { @@ -501,6 +564,9 @@ export interface paths { }; }; }; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; @@ -822,7 +888,9 @@ export interface paths { }; get: { parameters: { - query?: never; + query?: { + user_id?: string; + }; header?: { authorization?: string; }; @@ -831,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: { @@ -1040,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; @@ -1115,8 +1286,6 @@ export interface paths { /** @example any */ content?: unknown; /** @example any */ - ratingId?: unknown; - /** @example any */ postId?: unknown; /** @example any */ parentId?: unknown; @@ -1592,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; @@ -3630,8 +3741,12 @@ export interface components { profilePicture: string | null; country: string | null; city: string | null; + displayName?: string | null; favoriteGenres: string[]; favoriteMovies: string[]; + bio?: string | null; + eventsSaved: string[]; + eventsAttended: string[]; privateAccount: boolean; spoiler: boolean; /** Format: date-time */ @@ -3649,12 +3764,16 @@ export interface components { secondaryLanguage?: string[]; country?: string; city?: string; + displayName?: string | null; favoriteGenres?: string[]; favoriteMovies?: string[]; - bookmarkedToWatch?: string[]; - bookmarkedWatched?: string[]; + bio?: string | null; privateAccount?: boolean; spoiler?: boolean; + bookmarkedToWatch?: string[]; + bookmarkedWatched?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; }; UpdateUserProfileResponse: { message: string; @@ -3677,11 +3796,6 @@ export interface components { tags: string[]; date: string; votes: number; - UserProfile?: { - userId: string; - username: string | null; - }; - threadedComments?: unknown[]; }; GetUserCommentsResponse: { message: string;