From 918004aa1adab455c247c7b41834ff12d06f73c2 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Sat, 14 Jun 2025 15:22:15 +0100 Subject: [PATCH 1/5] feat: Introduce group functionality and related activities - Added new API route for managing groups. - Implemented Group and GroupMember models with associations. - Updated database seeding to create multiple groups and their members. - Enhanced activity logging to track group-related actions such as creation, joining, and scheduling. - Refactored existing match-related activities to accommodate group dynamics. --- backend/src/app.ts | 1 + backend/src/controllers/group.controller.ts | 323 +++++++++ backend/src/db/seeders.ts | 715 ++++++++++++++++---- backend/src/models/Group.ts | 129 ++++ backend/src/models/GroupMember.ts | 115 ++++ backend/src/models/GroupWatchlist.ts | 144 ++++ backend/src/models/index.ts | 35 + backend/src/routes/groups.ts | 43 ++ backend/src/services/activity.service.ts | 19 + backend/src/services/group.service.ts | 575 ++++++++++++++++ backend/src/types/index.ts | 1 + docs/GROUP_FEATURE_IMPLEMENTATION.md | 364 ++++++++++ 12 files changed, 2337 insertions(+), 127 deletions(-) create mode 100644 backend/src/controllers/group.controller.ts create mode 100644 backend/src/models/Group.ts create mode 100644 backend/src/models/GroupMember.ts create mode 100644 backend/src/models/GroupWatchlist.ts create mode 100644 backend/src/routes/groups.ts create mode 100644 backend/src/services/group.service.ts create mode 100644 docs/GROUP_FEATURE_IMPLEMENTATION.md diff --git a/backend/src/app.ts b/backend/src/app.ts index 4f3b0eb..1afe368 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -39,6 +39,7 @@ app.use('/api/email', emailRoutes); app.use('/api/users', userRoutes); app.use('/api/watchlist', watchlistRoutes); app.use('/api/matches', matchRoutes); +app.use('/api/groups', groupRoutes); app.use('/api/search', searchRoutes); app.use('/api/admin', adminRoutes); app.use('/api/activity', activityRoutes); diff --git a/backend/src/controllers/group.controller.ts b/backend/src/controllers/group.controller.ts new file mode 100644 index 0000000..f82ecd7 --- /dev/null +++ b/backend/src/controllers/group.controller.ts @@ -0,0 +1,323 @@ +import type { Request, Response } from 'express'; +import { ActivityType, activityService } from '../services/activity.service'; +import { + acceptGroupInvitationService, + addToGroupWatchlistService, + createGroupService, + createRelationshipService, + expandRelationshipService, + getGroupContentMatchesService, + getPendingInvitationsService, + getPrimaryRelationshipService, + getUserGroupsService, + inviteToGroupService, +} from '../services/group.service'; + +// Create a new relationship (replaces match creation) +export const createRelationship = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { partner_email, group_name, description } = req.body; + + if (!partner_email) { + return res.status(400).json({ error: 'partner_email is required' }); + } + + const group = await createRelationshipService(req.user, { + partner_email, + group_name, + description, + }); + + res.status(201).json(group); + } catch (error) { + console.error('Error creating relationship:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Get primary relationship (for frontend that supports one relationship) +export const getPrimaryRelationship = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const relationship = await getPrimaryRelationshipService(req.user); + + if (!relationship) { + return res.json(null); + } + + res.json(relationship); + } catch (error) { + console.error('Error getting primary relationship:', error); + if (error instanceof Error) { + res.status(500).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Create a new group (for advanced users wanting multiple groups) +export const createGroup = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { name, description, type, max_members, settings } = req.body; + + // Validate required fields + if (!name || !type) { + return res.status(400).json({ error: 'Name and type are required' }); + } + + // Validate group type + if (!['couple', 'friends', 'watch_party'].includes(type)) { + return res.status(400).json({ error: 'Invalid group type' }); + } + + const group = await createGroupService(req.user, { + name, + description, + type, + max_members, + settings, + }); + + res.status(201).json(group); + } catch (error) { + console.error('Error creating group:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Get user's groups (for users who have multiple) +export const getUserGroups = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const groups = await getUserGroupsService(req.user); + res.json(groups); + } catch (error) { + console.error('Error getting user groups:', error); + if (error instanceof Error) { + res.status(500).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Expand relationship (e.g., couple → friends → watch party) +export const expandRelationship = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { group_id } = req.params; + const { new_type, new_max_members, new_name } = req.body; + + if (!new_type || !['friends', 'watch_party'].includes(new_type)) { + return res + .status(400) + .json({ error: 'Invalid new_type. Must be friends or watch_party' }); + } + + const group = await expandRelationshipService( + req.user, + group_id, + new_type, + new_max_members, + new_name + ); + + res.json(group); + } catch (error) { + console.error('Error expanding relationship:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Invite users to group +export const inviteToGroup = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { group_id } = req.params; + const { user_ids } = req.body; + + if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) { + return res.status(400).json({ error: 'user_ids array is required' }); + } + + const invitations = await inviteToGroupService(req.user, { + group_id, + user_ids, + }); + + res.status(201).json(invitations); + } catch (error) { + console.error('Error inviting to group:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Accept group invitation +export const acceptGroupInvitation = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { group_id } = req.params; + + const membership = await acceptGroupInvitationService(req.user, group_id); + + res.json(membership); + } catch (error) { + console.error('Error accepting group invitation:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Decline group invitation +export const declineGroupInvitation = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { group_id } = req.params; + + // This would be implemented in the service + // For now, just return success + await activityService.logActivity( + req.user.user_id, + ActivityType.GROUP_INVITE_DECLINE, + { + group_id, + timestamp: new Date(), + } + ); + + res.json({ message: 'Group invitation declined' }); + } catch (error) { + console.error('Error declining group invitation:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Get group content matches +export const getGroupContentMatches = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { group_id } = req.params; + + const matches = await getGroupContentMatchesService(req.user, group_id); + + res.json(matches); + } catch (error) { + console.error('Error getting group content matches:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Add content to group watchlist +export const addToGroupWatchlist = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { group_id } = req.params; + const { tmdb_id, media_type, notes } = req.body; + + if (!tmdb_id || !media_type) { + return res + .status(400) + .json({ error: 'tmdb_id and media_type are required' }); + } + + if (!['movie', 'tv'].includes(media_type)) { + return res.status(400).json({ error: 'Invalid media_type' }); + } + + const groupEntry = await addToGroupWatchlistService( + req.user, + group_id, + tmdb_id, + media_type, + notes + ); + + res.status(201).json(groupEntry); + } catch (error) { + console.error('Error adding to group watchlist:', error); + if (error instanceof Error) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; + +// Get group invitations (pending invitations for the user) +export const getGroupInvitations = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const invitations = await getPendingInvitationsService(req.user); + res.json(invitations); + } catch (error) { + console.error('Error getting group invitations:', error); + if (error instanceof Error) { + res.status(500).json({ error: error.message }); + } else { + res.status(500).json({ error: 'Unknown error occurred' }); + } + } +}; diff --git a/backend/src/db/seeders.ts b/backend/src/db/seeders.ts index 08e4502..3b141e5 100644 --- a/backend/src/db/seeders.ts +++ b/backend/src/db/seeders.ts @@ -4,7 +4,8 @@ import AuditLog from '../models/AuditLog'; import Content from '../models/Content'; import ContentReport from '../models/ContentReport'; import EmailVerification from '../models/EmailVerification'; -import Match from '../models/Match'; +import Group from '../models/Group'; +import GroupMember from '../models/GroupMember'; import PasswordReset from '../models/PasswordReset'; import User from '../models/User'; import UserSession from '../models/UserSession'; @@ -23,7 +24,8 @@ export async function seedDatabase() { // Initialize all models User.initialize(sequelize); WatchlistEntry.initialize(sequelize); - Match.initialize(sequelize); + Group.initialize(sequelize); + GroupMember.initialize(sequelize); ActivityLog.initialize(sequelize); AuditLog.initialize(sequelize); AppSettings.initialize(sequelize); @@ -361,97 +363,403 @@ export async function seedDatabase() { created_at: new Date(), }); - // Create matches between users + // Create groups (couples) to replace matches + // Group 1: userActive + userBanned (active) + const group1 = await Group.create({ + name: 'useractive & userbanned', + type: 'couple', + status: 'active', + max_members: 2, + created_by: userActive.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group1.group_id, + user_id: userActive.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group1.group_id, + user_id: userBanned.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 2: user1 + user2 (active) + const group2 = await Group.create({ + name: 'user1 & user2', + type: 'couple', + status: 'active', + max_members: 2, + created_by: user1.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group2.group_id, + user_id: user1.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group2.group_id, + user_id: user2.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 3: user2 + user5 (active) + const group3 = await Group.create({ + name: 'user2 & user5', + type: 'couple', + status: 'active', + max_members: 2, + created_by: user2.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group3.group_id, + user_id: user2.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group3.group_id, + user_id: user5.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 4: user3 + user6 (active) + const group4 = await Group.create({ + name: 'user3 & user6', + type: 'couple', + status: 'active', + max_members: 2, + created_by: user3.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group4.group_id, + user_id: user3.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group4.group_id, + user_id: user6.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 5: user8 + user9 (active) + const group5 = await Group.create({ + name: 'user8 & user9', + type: 'couple', + status: 'active', + max_members: 2, + created_by: user8.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group5.group_id, + user_id: user8.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group5.group_id, + user_id: user9.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 6: user3 + user5 (active) + const group6 = await Group.create({ + name: 'user3 & user5', + type: 'couple', + status: 'active', + max_members: 2, + created_by: user3.user_id, + }); + await Promise.all([ - // Original match between userActive and userBanned - Match.create({ - user1_id: userActive.user_id, - user2_id: userBanned.user_id, - status: 'accepted', - }), - // Additional matches between new users - Match.create({ - user1_id: user1.user_id, - user2_id: user2.user_id, - status: 'accepted', - }), - Match.create({ - user1_id: user1.user_id, - user2_id: user3.user_id, - status: 'pending', - }), - Match.create({ - user1_id: user2.user_id, - user2_id: user5.user_id, - status: 'accepted', - }), - Match.create({ - user1_id: user3.user_id, - user2_id: user6.user_id, - status: 'accepted', - }), - Match.create({ - user1_id: user5.user_id, - user2_id: user6.user_id, - status: 'pending', - }), - Match.create({ - user1_id: user8.user_id, - user2_id: user9.user_id, - status: 'accepted', - }), - Match.create({ - user1_id: user1.user_id, - user2_id: user9.user_id, - status: 'rejected', - }), - Match.create({ - user1_id: user2.user_id, - user2_id: user8.user_id, - status: 'pending', - }), - Match.create({ - user1_id: user3.user_id, - user2_id: user5.user_id, - status: 'accepted', - }), - Match.create({ - user1_id: user6.user_id, - user2_id: user9.user_id, - status: 'accepted', - }), - // Include userActive in more matches - Match.create({ - user1_id: userActive.user_id, - user2_id: user1.user_id, - status: 'accepted', - }), - Match.create({ - user1_id: userActive.user_id, - user2_id: user5.user_id, - status: 'pending', - }), - // Include userSuspended in a match - Match.create({ - user1_id: userSuspended.user_id, - user2_id: user8.user_id, - status: 'accepted', - }), - // Additional matches to use all users - Match.create({ - user1_id: user4.user_id, - user2_id: user7.user_id, - status: 'accepted', - }), - Match.create({ - user1_id: user4.user_id, - user2_id: user10.user_id, - status: 'pending', - }), - Match.create({ - user1_id: user7.user_id, - user2_id: user10.user_id, - status: 'rejected', + GroupMember.create({ + group_id: group6.group_id, + user_id: user3.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group6.group_id, + user_id: user5.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 7: user6 + user9 (active) + const group7 = await Group.create({ + name: 'user6 & user9', + type: 'couple', + status: 'active', + max_members: 2, + created_by: user6.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group7.group_id, + user_id: user6.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group7.group_id, + user_id: user9.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 8: userActive + user1 (active) + const group8 = await Group.create({ + name: 'useractive & user1', + type: 'couple', + status: 'active', + max_members: 2, + created_by: userActive.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group8.group_id, + user_id: userActive.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group8.group_id, + user_id: user1.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 9: userSuspended + user8 (active) + const group9 = await Group.create({ + name: 'usersuspended & user8', + type: 'couple', + status: 'active', + max_members: 2, + created_by: userSuspended.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group9.group_id, + user_id: userSuspended.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group9.group_id, + user_id: user8.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Group 10: user4 + user7 (active) + const group10 = await Group.create({ + name: 'user4 & user7', + type: 'couple', + status: 'active', + max_members: 2, + created_by: user4.user_id, + }); + + await Promise.all([ + GroupMember.create({ + group_id: group10.group_id, + user_id: user4.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: group10.group_id, + user_id: user7.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Create a friend group with multiple members + const friendGroup = await Group.create({ + name: 'Movie Night Crew', + description: 'Weekly movie night friends', + type: 'friends', + status: 'active', + max_members: 8, + created_by: user1.user_id, + settings: { + isPublic: false, + requireApproval: true, + allowInvites: true, + scheduleSettings: { + recurringDay: 'friday', + recurringTime: '20:00', + }, + }, + }); + + // Add members to friend group + await Promise.all([ + GroupMember.create({ + group_id: friendGroup.group_id, + user_id: user1.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: friendGroup.group_id, + user_id: user3.user_id, + role: 'admin', + status: 'active', + }), + GroupMember.create({ + group_id: friendGroup.group_id, + user_id: user5.user_id, + role: 'member', + status: 'active', + }), + GroupMember.create({ + group_id: friendGroup.group_id, + user_id: user6.user_id, + role: 'member', + status: 'active', + }), + GroupMember.create({ + group_id: friendGroup.group_id, + user_id: user8.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Create a watch party group + const watchPartyGroup = await Group.create({ + name: 'Sunday Binge Watchers', + description: 'Sunday afternoon TV series binge watching', + type: 'watch_party', + status: 'active', + max_members: 12, + created_by: user2.user_id, + settings: { + isPublic: true, + requireApproval: false, + allowInvites: true, + scheduleSettings: { + recurringDay: 'sunday', + recurringTime: '14:00', + }, + }, + }); + + // Add members to watch party + await Promise.all([ + GroupMember.create({ + group_id: watchPartyGroup.group_id, + user_id: user2.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: watchPartyGroup.group_id, + user_id: user4.user_id, + role: 'admin', + status: 'active', + }), + GroupMember.create({ + group_id: watchPartyGroup.group_id, + user_id: user7.user_id, + role: 'member', + status: 'active', + }), + GroupMember.create({ + group_id: watchPartyGroup.group_id, + user_id: user9.user_id, + role: 'member', + status: 'active', + }), + GroupMember.create({ + group_id: watchPartyGroup.group_id, + user_id: user10.user_id, + role: 'member', + status: 'active', + }), + GroupMember.create({ + group_id: watchPartyGroup.group_id, + user_id: userActive.user_id, + role: 'member', + status: 'active', + }), + ]); + + // Create additional couple group for testing multiple relationships + // User3 is now in both a couple (group4 with user6) and friend group (friendGroup) + // User1 is in both a couple (group2 with user2) and friend group (friendGroup) + // userActive is in multiple groups: couple with userBanned, couple with user1, and watch party + + // Create another friend group with some overlapping members + const collegeGroup = await Group.create({ + name: 'College Alumni Watch Group', + description: 'Old college friends catching up through movies', + type: 'friends', + status: 'active', + max_members: 6, + created_by: user5.user_id, + settings: { + isPublic: false, + requireApproval: true, + allowInvites: false, + }, + }); + + await Promise.all([ + GroupMember.create({ + group_id: collegeGroup.group_id, + user_id: user5.user_id, + role: 'owner', + status: 'active', + }), + GroupMember.create({ + group_id: collegeGroup.group_id, + user_id: user6.user_id, + role: 'member', + status: 'active', + }), + GroupMember.create({ + group_id: collegeGroup.group_id, + user_id: user9.user_id, + role: 'member', + status: 'active', + }), + GroupMember.create({ + group_id: collegeGroup.group_id, + user_id: userBanned.user_id, + role: 'member', + status: 'active', }), ]); @@ -781,27 +1089,25 @@ export async function seedDatabase() { created_at: pastDate(5), }), - // Match activities + // Group activities ActivityLog.create({ user_id: userActive.user_id, - action: ActivityType.MATCH_CREATE, + action: ActivityType.GROUP_CREATE, + context: 'group', metadata: { - with_user: { - user_id: userBanned.user_id, - username: 'userbanned', - }, + group_id: group1.group_id, + group_name: 'useractive & userbanned', + group_type: 'couple', }, created_at: pastDate(22), }), ActivityLog.create({ user_id: userActive.user_id, - action: ActivityType.MATCH_VIEW, + action: ActivityType.GROUP_JOIN, + context: 'group', metadata: { - match_id: '1', - with_user: { - user_id: userBanned.user_id, - username: 'userbanned', - }, + group_id: group1.group_id, + group_name: 'useractive & userbanned', }, created_at: pastDate(4), }), @@ -887,20 +1193,15 @@ export async function seedDatabase() { created_at: pastDate(6), }), - // Match activities + // Group activities ActivityLog.create({ user_id: userBanned.user_id, - action: ActivityType.MATCH_UPDATE, + action: ActivityType.GROUP_JOIN, + context: 'group', metadata: { - match_id: '1', - with_user: { - user_id: userActive.user_id, - username: 'useractive', - }, - status: { - from: 'pending', - to: 'accepted', - }, + group_id: group1.group_id, + group_name: 'useractive & userbanned', + group_type: 'couple', }, created_at: pastDate(20), }), @@ -969,12 +1270,12 @@ export async function seedDatabase() { }), ActivityLog.create({ user_id: user1.user_id, - action: ActivityType.MATCH_CREATE, + action: ActivityType.GROUP_CREATE, + context: 'group', metadata: { - with_user: { - user_id: user2.user_id, - username: 'user2', - }, + group_id: group2.group_id, + group_name: 'user1 & user2', + group_type: 'couple', }, created_at: pastDate(5), }), @@ -1106,13 +1407,12 @@ export async function seedDatabase() { }), ActivityLog.create({ user_id: user9.user_id, - action: ActivityType.MATCH_VIEW, + action: ActivityType.GROUP_JOIN, + context: 'group', metadata: { - match_id: '8', - with_user: { - user_id: user8.user_id, - username: 'user8', - }, + group_id: group5.group_id, + group_name: 'user8 & user9', + group_type: 'couple', }, created_at: pastDate(2), }), @@ -1135,6 +1435,155 @@ export async function seedDatabase() { }), ]); + // Group-related activities for the new complex groups + await Promise.all([ + // Friend group creation and activities + ActivityLog.create({ + user_id: user1.user_id, + action: ActivityType.GROUP_CREATE, + context: 'group', + metadata: { + group_id: friendGroup.group_id, + group_name: 'Movie Night Crew', + group_type: 'friends', + }, + created_at: pastDate(18), + }), + ActivityLog.create({ + user_id: user3.user_id, + action: ActivityType.GROUP_JOIN, + context: 'group', + metadata: { + group_id: friendGroup.group_id, + group_name: 'Movie Night Crew', + group_type: 'friends', + }, + created_at: pastDate(17), + }), + ActivityLog.create({ + user_id: user5.user_id, + action: ActivityType.GROUP_JOIN, + context: 'group', + metadata: { + group_id: friendGroup.group_id, + group_name: 'Movie Night Crew', + group_type: 'friends', + }, + created_at: pastDate(16), + }), + + // Watch party creation and activities + ActivityLog.create({ + user_id: user2.user_id, + action: ActivityType.GROUP_CREATE, + context: 'group', + metadata: { + group_id: watchPartyGroup.group_id, + group_name: 'Sunday Binge Watchers', + group_type: 'watch_party', + }, + created_at: pastDate(12), + }), + ActivityLog.create({ + user_id: userActive.user_id, + action: ActivityType.GROUP_JOIN, + context: 'group', + metadata: { + group_id: watchPartyGroup.group_id, + group_name: 'Sunday Binge Watchers', + group_type: 'watch_party', + }, + created_at: pastDate(10), + }), + ActivityLog.create({ + user_id: user9.user_id, + action: ActivityType.GROUP_JOIN, + context: 'group', + metadata: { + group_id: watchPartyGroup.group_id, + group_name: 'Sunday Binge Watchers', + group_type: 'watch_party', + }, + created_at: pastDate(9), + }), + + // College group activities + ActivityLog.create({ + user_id: user5.user_id, + action: ActivityType.GROUP_CREATE, + context: 'group', + metadata: { + group_id: collegeGroup.group_id, + group_name: 'College Alumni Watch Group', + group_type: 'friends', + }, + created_at: pastDate(14), + }), + ActivityLog.create({ + user_id: user6.user_id, + action: ActivityType.GROUP_JOIN, + context: 'group', + metadata: { + group_id: collegeGroup.group_id, + group_name: 'College Alumni Watch Group', + group_type: 'friends', + }, + created_at: pastDate(13), + }), + ActivityLog.create({ + user_id: userBanned.user_id, + action: ActivityType.GROUP_JOIN, + context: 'group', + metadata: { + group_id: collegeGroup.group_id, + group_name: 'College Alumni Watch Group', + group_type: 'friends', + }, + created_at: pastDate(11), + }), + + // Some group watchlist activities + ActivityLog.create({ + user_id: user1.user_id, + action: ActivityType.GROUP_WATCHLIST_ADD, + context: 'group', + metadata: { + group_id: friendGroup.group_id, + group_name: 'Movie Night Crew', + title: sampleContent[0].title, + tmdb_id: sampleContent[0].tmdb_id, + media_type: sampleContent[0].media_type, + }, + created_at: pastDate(8), + }), + ActivityLog.create({ + user_id: user3.user_id, + action: ActivityType.GROUP_WATCHLIST_VOTE, + context: 'group', + metadata: { + group_id: friendGroup.group_id, + group_name: 'Movie Night Crew', + title: sampleContent[0].title, + vote: 'up', + }, + created_at: pastDate(7), + }), + ActivityLog.create({ + user_id: user2.user_id, + action: ActivityType.GROUP_SCHEDULE_SET, + context: 'group', + metadata: { + group_id: watchPartyGroup.group_id, + group_name: 'Sunday Binge Watchers', + schedule: { + day: 'sunday', + time: '14:00', + }, + }, + created_at: pastDate(6), + }), + ]); + // Additional admin activities related to system management await Promise.all([ // Existing admin activities @@ -1386,10 +1835,22 @@ export async function seedDatabase() { console.warn(' user9@example.com (verified, active)'); console.warn(' user10@example.com (verified, suspended)'); console.warn(''); + console.warn('Groups created:'); + console.warn(' • 10 couple groups (2 members each)'); + console.warn(' • 1 friend group: "Movie Night Crew" (5 members)'); + console.warn(' • 1 watch party: "Sunday Binge Watchers" (6 members)'); console.warn( - 'Matches created between users with different statuses (accepted, pending, rejected)' + ' • 1 college alumni group: "College Alumni Watch Group" (4 members)' ); console.warn(''); + console.warn('Users with multiple relationships:'); + console.warn(' • userActive: 2 couples + 1 watch party'); + console.warn(' • user1: 1 couple + 1 friend group'); + console.warn(' • user3: 1 couple + 1 friend group'); + console.warn(' • user5: 1 couple + 2 friend groups'); + console.warn(' • user6: 1 couple + 2 friend groups'); + console.warn(' • user9: 1 couple + 1 college group + 1 watch party'); + console.warn(''); console.warn('Email verification tokens:'); console.warn(' usersuspended verification: sample-verification-token-123'); console.warn(' useractive password reset: sample-reset-token-456'); diff --git a/backend/src/models/Group.ts b/backend/src/models/Group.ts new file mode 100644 index 0000000..0eb6520 --- /dev/null +++ b/backend/src/models/Group.ts @@ -0,0 +1,129 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import User from './User'; + +type GroupType = 'couple' | 'friends' | 'watch_party'; +type GroupStatus = 'active' | 'inactive' | 'archived'; + +interface GroupAttributes { + group_id: string; + name: string; + description?: string; + type: GroupType; + status: GroupStatus; + created_by: string; + max_members?: number; + settings: { + isPublic: boolean; + requireApproval: boolean; + allowInvites: boolean; + scheduleSettings?: { + recurringDay?: string; // 'monday', 'tuesday', etc. for weekly watch parties + recurringTime?: string; // '19:00' format + }; + }; + created_at: Date; + updated_at: Date; +} + +interface GroupCreationAttributes { + name: string; + description?: string; + type: GroupType; + created_by: string; + max_members?: number; + settings?: { + isPublic?: boolean; + requireApproval?: boolean; + allowInvites?: boolean; + scheduleSettings?: { + recurringDay?: string; + recurringTime?: string; + }; + }; + status?: GroupStatus; +} + +class Group extends Model { + declare group_id: string; + declare name: string; + declare description: string | null; + declare type: GroupType; + declare status: GroupStatus; + declare created_by: string; + declare max_members: number | null; + declare settings: GroupAttributes['settings']; + declare created_at: Date; + declare updated_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return Group.init( + { + group_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + type: { + type: DataTypes.ENUM('couple', 'friends', 'watch_party'), + allowNull: false, + }, + status: { + type: DataTypes.ENUM('active', 'inactive', 'archived'), + allowNull: false, + defaultValue: 'active', + }, + created_by: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: User, + key: 'user_id', + }, + }, + max_members: { + type: DataTypes.INTEGER, + allowNull: true, + validate: { + min: 2, + max: 50, // Reasonable limit for watch parties + }, + }, + settings: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: { + isPublic: false, + requireApproval: true, + allowInvites: true, + }, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'Group', + tableName: 'groups', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + } + ); + } +} + +export default Group; diff --git a/backend/src/models/GroupMember.ts b/backend/src/models/GroupMember.ts new file mode 100644 index 0000000..d394af6 --- /dev/null +++ b/backend/src/models/GroupMember.ts @@ -0,0 +1,115 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Group from './Group'; +import User from './User'; + +type MemberRole = 'owner' | 'admin' | 'member'; +type MemberStatus = 'pending' | 'active' | 'inactive' | 'removed'; + +interface GroupMemberAttributes { + membership_id: string; + group_id: string; + user_id: string; + role: MemberRole; + status: MemberStatus; + joined_at: Date; + invited_by?: string; + created_at: Date; + updated_at: Date; +} + +interface GroupMemberCreationAttributes { + group_id: string; + user_id: string; + role?: MemberRole; + status?: MemberStatus; + invited_by?: string; +} + +class GroupMember extends Model< + GroupMemberAttributes, + GroupMemberCreationAttributes +> { + declare membership_id: string; + declare group_id: string; + declare user_id: string; + declare role: MemberRole; + declare status: MemberStatus; + declare joined_at: Date; + declare invited_by: string | null; + declare created_at: Date; + declare updated_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return GroupMember.init( + { + membership_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + group_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: Group, + key: 'group_id', + }, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: User, + key: 'user_id', + }, + }, + role: { + type: DataTypes.ENUM('owner', 'admin', 'member'), + allowNull: false, + defaultValue: 'member', + }, + status: { + type: DataTypes.ENUM('pending', 'active', 'inactive', 'removed'), + allowNull: false, + defaultValue: 'pending', + }, + joined_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + invited_by: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: User, + key: 'user_id', + }, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'GroupMember', + tableName: 'group_members', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + unique: true, + fields: ['group_id', 'user_id'], + }, + ], + } + ); + } +} + +export default GroupMember; diff --git a/backend/src/models/GroupWatchlist.ts b/backend/src/models/GroupWatchlist.ts new file mode 100644 index 0000000..a1951b4 --- /dev/null +++ b/backend/src/models/GroupWatchlist.ts @@ -0,0 +1,144 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Group from './Group'; +import User from './User'; + +type GroupWatchlistStatus = + | 'suggested' + | 'voting' + | 'approved' + | 'watching' + | 'finished' + | 'rejected'; + +interface GroupWatchlistAttributes { + group_entry_id: string; + group_id: string; + tmdb_id: number; + media_type: 'movie' | 'tv'; + status: GroupWatchlistStatus; + suggested_by: string; + votes_for: number; + votes_against: number; + notes?: string; + scheduled_date?: Date; + created_at: Date; + updated_at: Date; +} + +interface GroupWatchlistCreationAttributes { + group_id: string; + tmdb_id: number; + media_type: 'movie' | 'tv'; + suggested_by: string; + status?: GroupWatchlistStatus; + notes?: string; + scheduled_date?: Date; +} + +class GroupWatchlist extends Model< + GroupWatchlistAttributes, + GroupWatchlistCreationAttributes +> { + declare group_entry_id: string; + declare group_id: string; + declare tmdb_id: number; + declare media_type: 'movie' | 'tv'; + declare status: GroupWatchlistStatus; + declare suggested_by: string; + declare votes_for: number; + declare votes_against: number; + declare notes: string | null; + declare scheduled_date: Date | null; + declare created_at: Date; + declare updated_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return GroupWatchlist.init( + { + group_entry_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + group_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: Group, + key: 'group_id', + }, + }, + tmdb_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + media_type: { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: false, + }, + status: { + type: DataTypes.ENUM( + 'suggested', + 'voting', + 'approved', + 'watching', + 'finished', + 'rejected' + ), + allowNull: false, + defaultValue: 'suggested', + }, + suggested_by: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: User, + key: 'user_id', + }, + }, + votes_for: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + votes_against: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + scheduled_date: { + type: DataTypes.DATE, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'GroupWatchlist', + tableName: 'group_watchlist', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + unique: true, + fields: ['group_id', 'tmdb_id'], + }, + ], + } + ); + } +} + +export default GroupWatchlist; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 3ec3f8c..d320854 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -28,6 +28,9 @@ export function initializeModels(sequelize: Sequelize) { Content.initialize(sequelize); // Then initialize models that depend on those + Group.initialize(sequelize); + GroupMember.initialize(sequelize); + GroupWatchlist.initialize(sequelize); Match.initialize(sequelize); ActivityLog.initialize(sequelize); AuditLog.initialize(sequelize); @@ -105,6 +108,35 @@ export function initializeModels(sequelize: Sequelize) { as: 'watchlistEntry', }); WatchlistEntry.hasMany(Match, { foreignKey: 'entry_id', as: 'matches' }); + + // Group associations + Group.belongsTo(User, { foreignKey: 'created_by', as: 'creator' }); + User.hasMany(Group, { foreignKey: 'created_by', as: 'createdGroups' }); + + // Group member associations (many-to-many through GroupMember) + Group.hasMany(GroupMember, { foreignKey: 'group_id', as: 'members' }); + User.hasMany(GroupMember, { + foreignKey: 'user_id', + as: 'groupMemberships', + }); + GroupMember.belongsTo(Group, { foreignKey: 'group_id', as: 'group' }); + GroupMember.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); + GroupMember.belongsTo(User, { foreignKey: 'invited_by', as: 'inviter' }); + + // Group watchlist associations + GroupWatchlist.belongsTo(Group, { foreignKey: 'group_id', as: 'group' }); + GroupWatchlist.belongsTo(User, { + foreignKey: 'suggested_by', + as: 'suggester', + }); + Group.hasMany(GroupWatchlist, { + foreignKey: 'group_id', + as: 'watchlistEntries', + }); + User.hasMany(GroupWatchlist, { + foreignKey: 'suggested_by', + as: 'suggestedEntries', + }); } catch (error) { console.error('Error initializing models:', error); throw new Error( @@ -125,4 +157,7 @@ export default { EmailVerification, PasswordReset, UserSession, + Group, + GroupMember, + GroupWatchlist, }; diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts new file mode 100644 index 0000000..cbe27a0 --- /dev/null +++ b/backend/src/routes/groups.ts @@ -0,0 +1,43 @@ +import { Router } from 'express'; +import { + acceptGroupInvitation, + addToGroupWatchlist, + createGroup, + createRelationship, + declineGroupInvitation, + expandRelationship, + getGroupContentMatches, + getGroupInvitations, + getPrimaryRelationship, + getUserGroups, + inviteToGroup, +} from '../controllers/group.controller'; +import { authenticateToken } from '../middlewares/auth'; + +const router = Router(); + +// All group routes require authentication +router.use(authenticateToken); + +// Primary relationship management (for frontend with one relationship) +router.post('/relationship', createRelationship); +router.get('/relationship', getPrimaryRelationship); + +// Group management (for advanced users) +router.post('/', createGroup); +router.get('/', getUserGroups); +router.get('/invitations', getGroupInvitations); + +// Group expansion and modification +router.put('/:group_id/expand', expandRelationship); + +// Group membership +router.post('/:group_id/invite', inviteToGroup); +router.post('/:group_id/accept', acceptGroupInvitation); +router.post('/:group_id/decline', declineGroupInvitation); + +// Group content +router.get('/:group_id/matches', getGroupContentMatches); +router.post('/:group_id/watchlist', addToGroupWatchlist); + +export default router; diff --git a/backend/src/services/activity.service.ts b/backend/src/services/activity.service.ts index 14f91c0..920fc81 100644 --- a/backend/src/services/activity.service.ts +++ b/backend/src/services/activity.service.ts @@ -42,6 +42,22 @@ export enum ActivityType { // Content moderation activities CONTENT_MODERATION = 'CONTENT_MODERATION', + + // Group related activities + GROUP_CREATE = 'GROUP_CREATE', + GROUP_UPDATE = 'GROUP_UPDATE', + GROUP_DELETE = 'GROUP_DELETE', + GROUP_JOIN = 'GROUP_JOIN', + GROUP_LEAVE = 'GROUP_LEAVE', + GROUP_INVITE = 'GROUP_INVITE', + GROUP_INVITE_ACCEPT = 'GROUP_INVITE_ACCEPT', + GROUP_INVITE_DECLINE = 'GROUP_INVITE_DECLINE', + GROUP_MEMBER_REMOVE = 'GROUP_MEMBER_REMOVE', + GROUP_ROLE_UPDATE = 'GROUP_ROLE_UPDATE', + GROUP_WATCHLIST_ADD = 'GROUP_WATCHLIST_ADD', + GROUP_WATCHLIST_REMOVE = 'GROUP_WATCHLIST_REMOVE', + GROUP_WATCHLIST_VOTE = 'GROUP_WATCHLIST_VOTE', + GROUP_SCHEDULE_SET = 'GROUP_SCHEDULE_SET', } /** @@ -99,6 +115,9 @@ export const getContextFromActivityType = ( if (actionString.startsWith('MATCH_')) { return 'match'; } + if (actionString.startsWith('GROUP_')) { + return 'group'; + } if (actionString.startsWith('MEDIA_')) { return 'media'; } diff --git a/backend/src/services/group.service.ts b/backend/src/services/group.service.ts new file mode 100644 index 0000000..a068cd5 --- /dev/null +++ b/backend/src/services/group.service.ts @@ -0,0 +1,575 @@ +import { Op } from 'sequelize'; +import models from '../models'; +import { ActivityType, activityService } from './activity.service'; + +const { Group, GroupMember, GroupWatchlist, User, WatchlistEntry } = models; + +interface AuthenticatedUser { + user_id: string; + email: string; + username: string; + role?: string; + status?: string; + email_verified?: boolean; + preferences?: Record; +} + +interface CreateGroupBody { + name: string; + description?: string; + type: 'couple' | 'friends' | 'watch_party'; + max_members?: number; + settings?: { + isPublic?: boolean; + requireApproval?: boolean; + allowInvites?: boolean; + scheduleSettings?: { + recurringDay?: string; + recurringTime?: string; + }; + }; +} + +interface InviteToGroupBody { + user_ids: string[]; + group_id: string; +} + +interface CreateRelationshipBody { + partner_email: string; + group_name?: string; + description?: string; +} + +// Create a new group +export const createGroupService = async ( + user: AuthenticatedUser, + body: CreateGroupBody +) => { + const { name, description, type, max_members, settings } = body; + + // Create the group + const group = await Group.create({ + name, + description, + type, + created_by: user.user_id, + max_members, + settings: { + isPublic: false, + requireApproval: true, + allowInvites: true, + ...settings, + }, + }); + + // Add creator as owner + await GroupMember.create({ + group_id: group.group_id, + user_id: user.user_id, + role: 'owner', + status: 'active', + }); + + // Log activity + await activityService.logActivity(user.user_id, ActivityType.GROUP_CREATE, { + group_id: group.group_id, + group_name: group.name, + group_type: group.type, + timestamp: new Date(), + }); + + return group; +}; + +// Create a couple relationship (replaces match creation) +export const createRelationshipService = async ( + user: AuthenticatedUser, + body: CreateRelationshipBody +) => { + const { partner_email, group_name, description } = body; + + // Find the partner user + const partner = await User.findOne({ where: { email: partner_email } }); + if (!partner) { + throw new Error('User not found with that email'); + } + + // Check if users are trying to create relationship with themselves + if (user.user_id === partner.user_id) { + throw new Error('Cannot create relationship with yourself'); + } + + // Check if relationship already exists between these users + const existingRelationship = await GroupMember.findOne({ + where: { + user_id: user.user_id, + }, + include: [ + { + model: Group, + as: 'group', + where: { type: 'couple' }, + include: [ + { + model: GroupMember, + as: 'members', + where: { user_id: partner.user_id, status: 'active' }, + required: true, + }, + ], + }, + ], + }); + + if (existingRelationship) { + throw new Error('Relationship already exists with this user'); + } + + // Create couple group + const defaultName = group_name || `${user.username} & ${partner.username}`; + const group = await createGroupService(user, { + name: defaultName, + description, + type: 'couple', + max_members: 2, + settings: { + isPublic: false, + requireApproval: false, // Couples don't need approval + allowInvites: false, // Start closed, can be changed later + }, + }); + + // Invite the partner (they'll need to accept) + await GroupMember.create({ + group_id: group.group_id, + user_id: partner.user_id, + role: 'member', + status: 'pending', + invited_by: user.user_id, + }); + + // Log activity + await activityService.logActivity(user.user_id, ActivityType.GROUP_INVITE, { + group_id: group.group_id, + group_name: group.name, + invited_user_id: partner.user_id, + timestamp: new Date(), + }); + + return group; +}; + +// Get user's primary relationship (for frontend that supports one relationship) +export const getPrimaryRelationshipService = async ( + user: AuthenticatedUser +) => { + const groupMembership = await GroupMember.findOne({ + where: { + user_id: user.user_id, + status: 'active', + }, + include: [ + { + model: Group, + as: 'group', + include: [ + { + model: User, + as: 'creator', + attributes: ['user_id', 'username', 'email'], + }, + { + model: GroupMember, + as: 'members', + where: { status: 'active' }, + include: [ + { + model: User, + as: 'user', + attributes: ['user_id', 'username', 'email'], + }, + ], + }, + ], + }, + ], + order: [['created_at', 'ASC']], // Get the first/primary relationship + }); + + if (!groupMembership) { + return null; + } + + return { + ...groupMembership.group?.toJSON(), + user_role: groupMembership.role, + member_count: groupMembership.group?.members?.length || 0, + }; +}; + +// Get user's groups (for users who want multiple groups later) +export const getUserGroupsService = async (user: AuthenticatedUser) => { + const groupMemberships = await GroupMember.findAll({ + where: { + user_id: user.user_id, + status: 'active', + }, + include: [ + { + model: Group, + as: 'group', + include: [ + { + model: User, + as: 'creator', + attributes: ['user_id', 'username', 'email'], + }, + { + model: GroupMember, + as: 'members', + where: { status: 'active' }, + include: [ + { + model: User, + as: 'user', + attributes: ['user_id', 'username', 'email'], + }, + ], + }, + ], + }, + ], + }); + + return groupMemberships.map(membership => ({ + ...membership.group?.toJSON(), + user_role: membership.role, + member_count: membership.group?.members?.length || 0, + })); +}; + +// Expand relationship (e.g., couple → friends group) +export const expandRelationshipService = async ( + user: AuthenticatedUser, + group_id: string, + new_type: 'friends' | 'watch_party', + new_max_members?: number, + new_name?: string +) => { + // Check if user has permission to modify (must be owner or admin) + const membership = await GroupMember.findOne({ + where: { + group_id, + user_id: user.user_id, + status: 'active', + role: ['owner', 'admin'], + }, + }); + + if (!membership) { + throw new Error('You do not have permission to modify this group'); + } + + // Get the group + const group = await Group.findByPk(group_id); + if (!group) { + throw new Error('Group not found'); + } + + // Update group settings for expansion + group.type = new_type; + group.max_members = new_max_members || (new_type === 'friends' ? 15 : 50); + if (new_name) { + group.name = new_name; + } + group.settings = { + ...group.settings, + allowInvites: true, // Enable invites for expanded groups + requireApproval: new_type === 'watch_party' ? false : true, + isPublic: new_type === 'watch_party' ? true : false, + }; + + await group.save(); + + // Log activity + await activityService.logActivity(user.user_id, ActivityType.GROUP_UPDATE, { + group_id, + group_name: group.name, + old_type: 'couple', + new_type, + timestamp: new Date(), + }); + + return group; +}; + +// Invite users to group +export const inviteToGroupService = async ( + user: AuthenticatedUser, + body: InviteToGroupBody +) => { + const { user_ids, group_id } = body; + + // Check if user has permission to invite (must be owner or admin) + const membership = await GroupMember.findOne({ + where: { + group_id, + user_id: user.user_id, + status: 'active', + role: ['owner', 'admin'], + }, + }); + + if (!membership) { + throw new Error('You do not have permission to invite users to this group'); + } + + // Get group details + const group = await Group.findByPk(group_id); + if (!group) { + throw new Error('Group not found'); + } + + // Check if group allows invites + if (!group.settings.allowInvites) { + throw new Error('This group does not allow invitations'); + } + + // Check group member limit + if (group.max_members) { + const currentMemberCount = await GroupMember.count({ + where: { group_id, status: ['active', 'pending'] }, + }); + + if (currentMemberCount + user_ids.length > group.max_members) { + throw new Error('Adding these users would exceed the group member limit'); + } + } + + // Create invitations + const invitations = await Promise.all( + user_ids.map(async user_id => { + // Check if user is already a member + const existingMembership = await GroupMember.findOne({ + where: { group_id, user_id }, + }); + + if (existingMembership) { + throw new Error( + `User ${user_id} is already a member or has been invited` + ); + } + + const invitation = await GroupMember.create({ + group_id, + user_id, + role: 'member', + status: group.settings.requireApproval ? 'pending' : 'active', + invited_by: user.user_id, + }); + + // Log activity + await activityService.logActivity( + user.user_id, + ActivityType.GROUP_INVITE, + { + group_id, + group_name: group.name, + invited_user_id: user_id, + timestamp: new Date(), + } + ); + + return invitation; + }) + ); + + return invitations; +}; + +// Accept group invitation +export const acceptGroupInvitationService = async ( + user: AuthenticatedUser, + group_id: string +) => { + const membership = await GroupMember.findOne({ + where: { + group_id, + user_id: user.user_id, + status: 'pending', + }, + }); + + if (!membership) { + throw new Error('No pending invitation found for this group'); + } + + membership.status = 'active'; + membership.joined_at = new Date(); + await membership.save(); + + // Log activity + await activityService.logActivity(user.user_id, ActivityType.GROUP_JOIN, { + group_id, + timestamp: new Date(), + }); + + return membership; +}; + +// Get group content matches (works for any group size) +export const getGroupContentMatchesService = async ( + user: AuthenticatedUser, + group_id: string +) => { + // Verify user is a member of the group + const membership = await GroupMember.findOne({ + where: { + group_id, + user_id: user.user_id, + status: 'active', + }, + }); + + if (!membership) { + throw new Error('You are not a member of this group'); + } + + // Get all active group members + const groupMembers = await GroupMember.findAll({ + where: { + group_id, + status: 'active', + }, + }); + + const memberIds = groupMembers.map(member => member.user_id); + + // Get all watchlist entries from group members + const memberWatchlistEntries = await WatchlistEntry.findAll({ + where: { + user_id: { [Op.in]: memberIds }, + }, + }); + + // Find content that appears in multiple members' watchlists + const contentMap = new Map< + number, + { + tmdb_id: number; + media_type: string; + users: { user_id: string; status: string; username?: string }[]; + } + >(); + + memberWatchlistEntries.forEach(entry => { + if (!contentMap.has(entry.tmdb_id)) { + contentMap.set(entry.tmdb_id, { + tmdb_id: entry.tmdb_id, + media_type: entry.media_type, + users: [], + }); + } + contentMap.get(entry.tmdb_id)!.users.push({ + user_id: entry.user_id, + status: entry.status, + }); + }); + + // Filter to only show content that 2+ members have + const matches = Array.from(contentMap.values()).filter( + content => content.users.length >= 2 + ); + + return matches; +}; + +// Add content to group watchlist +export const addToGroupWatchlistService = async ( + user: AuthenticatedUser, + group_id: string, + tmdb_id: number, + media_type: 'movie' | 'tv', + notes?: string +) => { + // Verify user is a member + const membership = await GroupMember.findOne({ + where: { + group_id, + user_id: user.user_id, + status: 'active', + }, + }); + + if (!membership) { + throw new Error('You are not a member of this group'); + } + + // Check if content already exists in group watchlist + const existingEntry = await GroupWatchlist.findOne({ + where: { group_id, tmdb_id }, + }); + + if (existingEntry) { + throw new Error('This content is already in the group watchlist'); + } + + // Add to group watchlist + const groupEntry = await GroupWatchlist.create({ + group_id, + tmdb_id, + media_type, + suggested_by: user.user_id, + notes, + status: 'suggested', + }); + + // Log activity + await activityService.logActivity( + user.user_id, + ActivityType.GROUP_WATCHLIST_ADD, + { + group_id, + tmdb_id, + media_type, + timestamp: new Date(), + } + ); + + return groupEntry; +}; + +// Get pending invitations for user +export const getPendingInvitationsService = async (user: AuthenticatedUser) => { + const pendingInvitations = await GroupMember.findAll({ + where: { + user_id: user.user_id, + status: 'pending', + }, + include: [ + { + model: Group, + as: 'group', + include: [ + { + model: User, + as: 'creator', + attributes: ['user_id', 'username', 'email'], + }, + ], + }, + { + model: User, + as: 'inviter', + attributes: ['user_id', 'username', 'email'], + }, + ], + }); + + return pendingInvitations.map(invitation => ({ + ...invitation.toJSON(), + group: invitation.group?.toJSON(), + inviter: invitation.inviter?.toJSON(), + })); +}; diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 7022229..36153a3 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -158,6 +158,7 @@ export type ActivityContext = | 'watchlist' | 'user' | 'match' + | 'group' | 'search' | 'media' | 'system'; diff --git a/docs/GROUP_FEATURE_IMPLEMENTATION.md b/docs/GROUP_FEATURE_IMPLEMENTATION.md new file mode 100644 index 0000000..5ecd79f --- /dev/null +++ b/docs/GROUP_FEATURE_IMPLEMENTATION.md @@ -0,0 +1,364 @@ +# Unified Groups Implementation Guide + +This document outlines the **unified groups model** that replaces both the existing 1-to-1 matching system and adds multi-user groups in PairFlix. + +## Architecture Overview + +### Old System (1-to-1 Matches) + +- Users could only have one match +- Limited to two people +- Separate Match model and UI + +### New Unified System (Groups for Everything) + +- **Everything is a group** with different types and member limits +- **Natural progression**: couple → friends → watch party +- **Single relationship model** that scales from 2 to 50 members +- **No separate match/group conversion** - seamless evolution + +## Database Schema + +### Single Unified Model: + +1. **Group Model** (`groups` table) + + - Types: `couple` (2 members), `friends` (2-15 members), `watch_party` (2-50 members) + - Flexible settings and member limits + - Natural evolution between types + +2. **GroupMember Model** (`group_members` table) + + - Handles all relationships (couples, friends, watch parties) + - Roles: `owner`, `admin`, `member` + - Status: `pending`, `active`, `inactive`, `removed` + +3. **GroupWatchlist Model** (`group_watchlist` table) + - Shared watchlist for any group size + - Voting system scales from couples to large groups + +## Natural Relationship Evolution + +### 1. Dating/Couples (Start Here) + +```javascript +// User invites partner via email +const relationship = await groups.createRelationship({ + partner_email: 'partner@example.com', + group_name: 'John & Jane', // Optional custom name + description: 'Our movie nights', +}); +// Creates a 2-person "couple" group +``` + +### 2. Add Friends (Expand Naturally) + +```javascript +// Expand couple to friends group +const friendsGroup = await groups.expandRelationship(groupId, { + new_type: 'friends', + new_max_members: 8, + new_name: 'Movie Night Crew', +}); +// Same group, now allows more members and invites +``` + +### 3. Large Watch Parties (Scale Further) + +```javascript +// Expand to public watch party +const watchParty = await groups.expandRelationship(groupId, { + new_type: 'watch_party', + new_max_members: 20, + new_name: 'Thursday Movie Nights', +}); +// Same group, now public with scheduling features +``` + +## Frontend Implementation + +### 1. Simplified Navigation + +Since frontend only supports one relationship, this is perfect: + +```javascript +// app.client/src/config/navigation.ts +const config: NavigationConfig = { + sections: [ + { + items: [ + { + key: 'watchlist', + label: 'My Watchlist', + path: '/watchlist', + icon: React.createElement(HiListBullet), + }, + // CHANGED: Single relationship instead of "matches" + { + key: 'relationship', + label: 'My Group', + path: '/relationship', + icon: React.createElement(HiUsers), + }, + { + key: 'activity', + label: 'Activity', + path: '/activity', + icon: React.createElement(HiChartBarSquare), + }, + ], + }, + ], +}; +``` + +### 2. Unified API Service + +Replace `matches` API with unified `relationships`: + +```javascript +// app.client/src/services/api/relationships.ts +export const relationships = { + // Get primary relationship (replaces getMatches) + getPrimary: async () => { + return fetchWithAuth('/api/groups/relationship'); + }, + + // Create relationship (replaces match creation) + create: async (partnerEmail: string, groupName?: string) => { + return fetchWithAuth('/api/groups/relationship', { + method: 'POST', + body: JSON.stringify({ + partner_email: partnerEmail, + group_name: groupName + }), + }); + }, + + // Accept relationship invitation + accept: async (groupId: string) => { + return fetchWithAuth(`/api/groups/${groupId}/accept`, { + method: 'POST', + }); + }, + + // Expand relationship (couple → friends → watch party) + expand: async (groupId: string, newType: string, maxMembers?: number) => { + return fetchWithAuth(`/api/groups/${groupId}/expand`, { + method: 'PUT', + body: JSON.stringify({ + new_type: newType, + new_max_members: maxMembers + }), + }); + }, + + // Get content matches for group + getContentMatches: async (groupId: string) => { + return fetchWithAuth(`/api/groups/${groupId}/matches`); + }, + + // Invite friends to group + invite: async (groupId: string, userIds: string[]) => { + return fetchWithAuth(`/api/groups/${groupId}/invite`, { + method: 'POST', + body: JSON.stringify({ user_ids: userIds }), + }); + }, +}; +``` + +### 3. Unified Relationship Page + +Replace `MatchPage` with `RelationshipPage`: + +```javascript +// app.client/src/features/relationships/RelationshipPage.tsx +const RelationshipPage: React.FC = () => { + const { data: relationship, isLoading } = useQuery( + ['relationship'], + () => relationships.getPrimary() + ); + + const { data: pendingInvitations = [] } = useQuery( + ['invitations'], + () => relationships.getInvitations() + ); + + if (isLoading) return ; + + // No relationship yet - show invitation form + if (!relationship) { + return ( + +

Start Your Movie Journey

+ + {pendingInvitations.length > 0 && ( + + )} + + +
+ ); + } + + // Has relationship - show unified interface + return ( + + + + +
+ +
+ +
+ + + {/* Natural expansion options */} + {relationship.type === 'couple' && ( + + )} + + {relationship.type === 'friends' && ( + + )} + + {relationship.settings.allowInvites && ( + + )} +
+
+
+ ); +}; +``` + +### 4. Progressive Enhancement Components + +```javascript +// Expansion cards that guide users naturally +const ExpandToFriendsCard: React.FC<{ groupId: string }> = ({ groupId }) => { + const expandMutation = useMutation( + ({ newType, maxMembers }: { newType: string; maxMembers: number }) => + relationships.expand(groupId, newType, maxMembers) + ); + + return ( + + +

Invite Friends?

+ + Turn your couple group into a friends circle and invite others + to discover movies together. + + +
+
+ ); +}; +``` + +## Migration Strategy + +### Phase 1: Backend Migration ✅ + +- [x] Create unified Group models +- [x] Remove Match model dependencies +- [x] Create relationship-focused services +- [x] Update API endpoints + +### Phase 2: Frontend Refactor + +- [ ] Replace `MatchPage` with `RelationshipPage` +- [ ] Update navigation from "Matches" to "My Group" +- [ ] Create expansion flow components +- [ ] Update API calls to use groups endpoints + +### Phase 3: Data Migration + +```sql +-- Migrate existing matches to couple groups +INSERT INTO groups (name, type, max_members, created_by, settings) +SELECT + CONCAT(u1.username, ' & ', u2.username) as name, + 'couple' as type, + 2 as max_members, + m.user1_id as created_by, + '{"isPublic": false, "requireApproval": false, "allowInvites": false}' as settings +FROM matches m +JOIN users u1 ON m.user1_id = u1.user_id +JOIN users u2 ON m.user2_id = u2.user_id +WHERE m.status = 'accepted'; + +-- Create group memberships for both users +INSERT INTO group_members (group_id, user_id, role, status) +SELECT g.group_id, m.user1_id, 'owner', 'active' +FROM groups g, matches m WHERE /* match conditions */; +``` + +### Phase 4: Polish & Features + +- [ ] Group watchlist voting +- [ ] Watch party scheduling +- [ ] Mobile optimization +- [ ] Performance tuning + +## Benefits of Unified Model + +### 1. **Perfect for Your Constraint** + +- Frontend supports one relationship ✅ +- Natural progression path ✅ +- No complex match/group conversions ✅ + +### 2. **Simpler Architecture** + +- One relationship model instead of two +- Single API endpoint set +- Unified UI components + +### 3. **Better User Experience** + +- Start simple (2 people) +- Grow naturally (add friends) +- Scale organically (watch parties) +- No jarring "conversion" steps + +### 4. **Future-Proof** + +- Easily support multiple groups later +- Natural expansion for different group types +- Flexible member limits and settings + +## Example User Journeys + +### Simple Couple: + +1. **Invite partner** via email +2. **Partner accepts** → 2-person couple group +3. **Discover content** together +4. **Stay as couple** or expand later + +### Growing Friend Circle: + +1. **Start as couple** (2 people) +2. **Expand to friends** (allow 8 members) +3. **Invite 3-4 friends** +4. **Group content discovery** +5. **Shared voting** on what to watch + +### Watch Party Evolution: + +1. **Friends group** (6 people) +2. **Expand to watch party** (allow 20 members) +3. **Make public** for discovery +4. **Set recurring schedule** (Thursday 8PM) +5. **Weekly movie selection** + +This unified approach eliminates the complexity of separate match/group systems while providing a natural growth path that works perfectly with your frontend's one-relationship constraint. From 85296059485215663e05c6abfe367ea46bc4579a Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Sat, 14 Jun 2025 16:42:09 +0100 Subject: [PATCH 2/5] feat: Implement unified groups feature - Introduced a new groups model to replace the existing matching system, allowing for multiple user types (couples, friends, watch parties). - Added group management functionalities including creating, inviting, and managing group memberships. - Updated the frontend to reflect the new groups structure, including navigation changes and UI components for group interactions. - Removed legacy match-related components and APIs to streamline the application. - Enhanced backend services to support group-related operations and activities. --- .../dashboard/AdminDashboardContent.tsx | 8 +- .../dashboard/SystemMonitoringContent.tsx | 2 +- .../admin/components/shared/StatsOverview.tsx | 17 +- app.admin/src/pages/SystemStats.tsx | 6 +- app.client/src/components/layout/Routes.tsx | 15 +- app.client/src/config/navigation.ts | 9 +- app.client/src/contexts/SettingsContext.tsx | 6 +- .../components/CreateRelationshipForm.tsx | 166 +++++++ .../features/groups/components/GroupCard.tsx | 151 ++++++ .../groups/components/GroupInvitations.tsx | 84 ++++ .../groups/hooks/useGroupInvitations.ts | 128 +++++ .../features/groups/hooks/useGroupMembers.ts | 172 +++++++ .../groups/hooks/useGroupWatchlist.ts | 211 ++++++++ .../src/features/groups/hooks/useGroups.ts | 210 ++++++++ app.client/src/features/groups/index.ts | 23 + .../src/features/groups/pages/GroupsPage.tsx | 256 ++++++++++ app.client/src/features/match/InviteUser.tsx | 82 ---- .../src/features/match/MatchFeature.tsx | 43 -- app.client/src/features/match/MatchPage.tsx | 464 ------------------ .../src/features/match/Recommendations.tsx | 181 ------- .../match/__tests__/MatchPage.test.tsx | 348 ------------- .../src/features/watchlist/WatchlistPage.tsx | 2 +- app.client/src/services/api/groups.ts | 188 +++++++ app.client/src/services/api/index.ts | 7 +- app.client/src/services/api/matches.ts | 45 -- app.client/src/types/group.ts | 142 ++++++ backend/src/models/index.ts | 3 + .../layout/examples/navigationConfigs.ts | 6 +- 28 files changed, 1777 insertions(+), 1198 deletions(-) create mode 100644 app.client/src/features/groups/components/CreateRelationshipForm.tsx create mode 100644 app.client/src/features/groups/components/GroupCard.tsx create mode 100644 app.client/src/features/groups/components/GroupInvitations.tsx create mode 100644 app.client/src/features/groups/hooks/useGroupInvitations.ts create mode 100644 app.client/src/features/groups/hooks/useGroupMembers.ts create mode 100644 app.client/src/features/groups/hooks/useGroupWatchlist.ts create mode 100644 app.client/src/features/groups/hooks/useGroups.ts create mode 100644 app.client/src/features/groups/index.ts create mode 100644 app.client/src/features/groups/pages/GroupsPage.tsx delete mode 100644 app.client/src/features/match/InviteUser.tsx delete mode 100644 app.client/src/features/match/MatchFeature.tsx delete mode 100644 app.client/src/features/match/MatchPage.tsx delete mode 100644 app.client/src/features/match/Recommendations.tsx delete mode 100644 app.client/src/features/match/__tests__/MatchPage.test.tsx create mode 100644 app.client/src/services/api/groups.ts delete mode 100644 app.client/src/services/api/matches.ts create mode 100644 app.client/src/types/group.ts diff --git a/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx b/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx index 3d16ad5..a2f2cfe 100644 --- a/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx +++ b/app.admin/src/features/admin/components/dashboard/AdminDashboardContent.tsx @@ -39,7 +39,7 @@ interface DashboardMetrics { }; content: { watchlistEntries: number; - matches: number; + groups: number; }; system: { status?: 'healthy' | 'unhealthy'; @@ -85,7 +85,7 @@ const AdminDashboardContent: React.FC = () => { }, content: { watchlistEntries: metricsData.watchlistEntries, - matches: metricsData.totalMatches, + groups: metricsData.totalGroups, }, // Add placeholder system data for the system health card system: { @@ -130,7 +130,7 @@ const AdminDashboardContent: React.FC = () => { }, content: { watchlistEntries: metricsData.watchlistEntries, - matches: metricsData.totalMatches, + groups: metricsData.totalGroups, }, // Add placeholder system data for the system health card system: { @@ -194,7 +194,7 @@ const AdminDashboardContent: React.FC = () => { <> Quick Actions diff --git a/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx b/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx index a82c979..ba740ed 100644 --- a/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx +++ b/app.admin/src/features/admin/components/dashboard/SystemMonitoringContent.tsx @@ -266,7 +266,7 @@ const SystemMonitoringContent: React.FC = () => { current: data.database?.activeUsers || 0, peak24h: data.database?.totalUsers || 0, total24h: - data.database?.contentStats?.matches || + data.database?.contentStats?.groups || Math.round(data.database?.totalUsers * 1.5) || 0, }, diff --git a/app.admin/src/features/admin/components/shared/StatsOverview.tsx b/app.admin/src/features/admin/components/shared/StatsOverview.tsx index 9a2ddbb..8e52f9e 100644 --- a/app.admin/src/features/admin/components/shared/StatsOverview.tsx +++ b/app.admin/src/features/admin/components/shared/StatsOverview.tsx @@ -11,7 +11,6 @@ import { FaChartLine, FaClock, FaExclamationTriangle, - FaHeart, FaHeartbeat, FaList, FaMemory, @@ -115,7 +114,7 @@ export type StatsCardType = | 'users' | 'activeUsers' | 'content' - | 'matches' + | 'groups' | 'activity' | 'errors' | 'systemHealth' @@ -130,7 +129,7 @@ interface MetricsType { }; content?: { watchlistEntries: number; - matches: number; + groups: number; }; activity?: { last24Hours: number; @@ -184,8 +183,8 @@ const MetricsCard: React.FC = ({ type, metrics, icon }) => { return ; case 'content': return ; - case 'matches': - return ; + case 'groups': + return ; case 'activity': return ; case 'errors': @@ -255,7 +254,7 @@ const MetricsCard: React.FC = ({ type, metrics, icon }) => { ); - case 'matches': + case 'groups': if (!metrics.content) return null; return ( @@ -263,8 +262,8 @@ const MetricsCard: React.FC = ({ type, metrics, icon }) => { {icon && {renderIcon(type)}}
- {formatNumber(metrics.content.matches)} - Total Matches + {formatNumber(metrics.content.groups)} + Total Groups
@@ -404,7 +403,7 @@ interface StatsOverviewProps { // Main component for displaying stats in a grid export const StatsOverview: React.FC = ({ metrics, - cards = ['users', 'activeUsers', 'content', 'matches'], + cards = ['users', 'activeUsers', 'content', 'groups'], columns = 4, }) => { if (!isValidMetrics(metrics)) return null; diff --git a/app.admin/src/pages/SystemStats.tsx b/app.admin/src/pages/SystemStats.tsx index 3b324e2..1dc3bd6 100644 --- a/app.admin/src/pages/SystemStats.tsx +++ b/app.admin/src/pages/SystemStats.tsx @@ -52,7 +52,7 @@ interface SystemStats { inactivePercentage: number; contentStats: { watchlistEntries: number; - matches: number; + groups: number; averageWatchlistPerUser: number; }; errorCount: number; @@ -199,9 +199,9 @@ const SystemStats: React.FC = () => { - Total Matches + Total Groups - {stats.database.contentStats.matches.toLocaleString()} + {stats.database.contentStats.groups.toLocaleString()} diff --git a/app.client/src/components/layout/Routes.tsx b/app.client/src/components/layout/Routes.tsx index d5d3bf3..16e409f 100644 --- a/app.client/src/components/layout/Routes.tsx +++ b/app.client/src/components/layout/Routes.tsx @@ -12,7 +12,6 @@ import LoginPage from '../../features/auth/LoginPage'; import ProfilePage from '../../features/auth/ProfilePage'; import RegisterPage from '../../features/auth/RegisterPage'; import ResetPasswordPage from '../../features/auth/ResetPasswordPage'; -import MatchPage from '../../features/match/MatchPage'; import WatchlistPage from '../../features/watchlist/WatchlistPage'; import { useAuth } from '../../hooks/useAuth'; @@ -82,9 +81,19 @@ const AppRoutes: React.FC = () => { element={} />} /> } />} + path="/groups" + element={} />} /> + } />} + /> + } />} + /> + {/* Legacy redirect for old matches route */} + } /> } />} diff --git a/app.client/src/config/navigation.ts b/app.client/src/config/navigation.ts index 1fd518d..6ed1870 100644 --- a/app.client/src/config/navigation.ts +++ b/app.client/src/config/navigation.ts @@ -4,7 +4,6 @@ import { HiArrowLeftOnRectangle, HiArrowRightOnRectangle, HiChartBarSquare, - HiHeart, HiListBullet, HiUser, } from 'react-icons/hi2'; @@ -56,10 +55,10 @@ export const createClientNavigation = ( icon: React.createElement(HiListBullet), }, { - key: 'matches', - label: 'Matches', - path: '/matches', - icon: React.createElement(HiHeart), + key: 'groups', + label: 'Groups', + path: '/groups', + icon: React.createElement(HiUserGroup), }, { key: 'activity', diff --git a/app.client/src/contexts/SettingsContext.tsx b/app.client/src/contexts/SettingsContext.tsx index 0cf9ff6..8f9dd98 100644 --- a/app.client/src/contexts/SettingsContext.tsx +++ b/app.client/src/contexts/SettingsContext.tsx @@ -20,7 +20,7 @@ interface AppSettings { features: { enableNotifications: boolean; enableUserProfiles: boolean; - enableMatching: boolean; // Added new feature flag + enableGroups: boolean; // Added new feature flag }; security: { sessionTimeout: number; // session timeout in minutes @@ -53,7 +53,7 @@ const defaultSettings: AppSettings = { features: { enableNotifications: true, enableUserProfiles: false, // Added missing property with default value - enableMatching: true, // Added with default value true + enableGroups: true, // Added with default value true }, security: { sessionTimeout: 30, // default session timeout in minutes @@ -115,7 +115,7 @@ export const SettingsProvider: React.FC = ({ features: { enableNotifications: adminSettings.features.enableNotifications, enableUserProfiles: adminSettings.features.enableUserProfiles, // Added missing property - enableMatching: adminSettings.features.enableMatching, // Added new feature flag + enableGroups: adminSettings.features.enableGroups, // Added new feature flag }, security: { sessionTimeout: adminSettings.security.sessionTimeout, diff --git a/app.client/src/features/groups/components/CreateRelationshipForm.tsx b/app.client/src/features/groups/components/CreateRelationshipForm.tsx new file mode 100644 index 0000000..d5379b5 --- /dev/null +++ b/app.client/src/features/groups/components/CreateRelationshipForm.tsx @@ -0,0 +1,166 @@ +import { Button, Input, TextArea } from '@pairflix/components'; +import React, { useState } from 'react'; +import type { CreateRelationshipRequest } from '../../../types/group'; + +interface CreateRelationshipFormProps { + onSubmit: (data: CreateRelationshipRequest) => Promise; + onCancel: () => void; + loading?: boolean; +} + +export default function CreateRelationshipForm({ + onSubmit, + onCancel, + loading = false, +}: CreateRelationshipFormProps) { + const [formData, setFormData] = useState({ + partner_email: '', + group_name: '', + description: '', + }); + const [errors, setErrors] = useState>({}); + + const handleInputChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // Clear error when user starts typing + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: '' })); + } + }; + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!formData.partner_email.trim()) { + newErrors.partner_email = 'Partner email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.partner_email)) { + newErrors.partner_email = 'Please enter a valid email address'; + } + + if (!formData.group_name.trim()) { + newErrors.group_name = 'Relationship name is required'; + } else if (formData.group_name.trim().length < 2) { + newErrors.group_name = 'Relationship name must be at least 2 characters'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + await onSubmit({ + partner_email: formData.partner_email.trim(), + group_name: formData.group_name.trim(), + description: formData.description?.trim() || undefined, + }); + } catch { + // Error handling is managed by the parent component + } + }; + + return ( +
+
+
+ + +

+ Your partner will receive an invitation to join this relationship +

+
+ +
+ + +
+ +
+ +