diff --git a/client/src/components/chat/ContactForm.tsx b/client/src/components/chat/ContactForm.tsx index 1db9d56..c846146 100644 --- a/client/src/components/chat/ContactForm.tsx +++ b/client/src/components/chat/ContactForm.tsx @@ -122,7 +122,7 @@ function ContactForm({ InitializeContact, setContactsList }: ContactFormProps) { void; currentContact: ContactsProps | null; }; function ParticipantsBar({ - contact, initializeContact, currentContact, }: ParticipantsBarProps) { const [participants, setParticipants] = useState([]); const [isGroupAdmin, setIsGroupAdmin] = useState(false); const user: UsernameType = useOutletContext(); - const [isLoading, setIsLoading] = useState(false); const getParticipants = async () => { try { const response = await axiosClient.get( - `/api/chat/groups/getMembers/${contact?.conversation_id}`, + `/api/chat/groups/getMembers/${currentContact?.conversation_id}`, ); console.log( 'getParticipants for: ', - contact?.conversation_id, + currentContact?.conversation_id, 'response: ', response.data, ); @@ -52,6 +49,60 @@ function ParticipantsBar({ } }; + const handleRemoveUser = async (userId: string) => { + socket?.emit( + 'remove user from group', + { + group_id: currentContact?.conversation_id, + user_id: userId, + }, + (response: { status: 'ok' | 'error'; message: string }) => { + if (response.status == 'ok') { + console.log(response.message); + } else { + console.error('Failed to remove user from group: ', response.message); + } + }, + ); + // setParticipants((prevMembers) => + // prevMembers.filter((member) => member.user_id !== userId), + // ); + }; + + const handleAddToAdministrators = async (userId: string) => { + socket?.emit( + 'added administrator', + { group_id: currentContact?.conversation_id, user_id: userId }, + (response: { status: 'ok' | 'error'; message: string }) => { + if (response.status == 'ok') { + console.log(response.message); + } else { + console.error( + 'Failed to add user to administrators: ', + response.message, + ); + } + }, + ); + }; + + const handleRemoveFromAdministrators = async (userId: string) => { + socket?.emit( + 'removed administrator', + { group_id: currentContact?.conversation_id, user_id: userId }, + (response: { status: 'ok' | 'error'; message: string }) => { + if (response.status == 'ok') { + console.log(response.message); + } else { + console.error( + 'Failed to remove user from administrators: ', + response.message, + ); + } + }, + ); + }; + useEffect(() => { if (participants.length > 0 && user?.user_id) { const userIsAdmin = participants.some( @@ -63,19 +114,20 @@ function ParticipantsBar({ }, [participants, user?.user_id]); useEffect(() => { - if (contact) { + if (currentContact) { getParticipants(); } - }, [contact]); + }, [currentContact]); useEffect(() => { - if (!socket || !contact) return; + if (!socket || !currentContact) return; const handleAddedToGroup = (msg: { username: string; user_id: string; group_id: string; - isadmin: false; + isadmin: boolean; + isowner: boolean; }) => { const { group_id } = msg; if ( @@ -93,7 +145,7 @@ function ParticipantsBar({ }); } - if (group_id === contact.conversation_id) { + if (group_id === currentContact.conversation_id) { setParticipants((prevMembers) => { const existingMember = prevMembers.some( (m) => m.user_id === msg.user_id, @@ -111,6 +163,7 @@ function ParticipantsBar({ msg.group_id == currentContact?.conversation_id && msg.user_id == user?.user_id ) { + setParticipants([]); initializeContact(currentContact); } setParticipants((prevMembers) => @@ -118,6 +171,36 @@ function ParticipantsBar({ ); }; + const handleAddToAdmins = (msg: { user_id: string; group_id: string }) => { + if (msg.group_id === currentContact.conversation_id) { + setParticipants((prevMembers) => + prevMembers.map((member) => + member.user_id === msg.user_id + ? { ...member, isadmin: true } + : member, + ), + ); + } + }; + + const handleRemoveFromAdmins = (msg: { + user_id: string; + group_id: string; + }) => { + console.log('(socket) removed administrator: ', msg); + if (msg.group_id === currentContact.conversation_id) { + setParticipants((prevMembers) => + prevMembers.map((member) => + member.user_id === msg.user_id + ? { ...member, isadmin: false } + : member, + ), + ); + } + }; + + socket.on('added administrator', handleAddToAdmins); + socket.on('removed administrator', handleRemoveFromAdmins); socket.on('added to group', handleAddedToGroup); socket.on('left group', handleLeftGroup); @@ -125,36 +208,7 @@ function ParticipantsBar({ socket?.off('added to group', handleAddedToGroup); socket?.off('left group', handleLeftGroup); }; - }, [socket, contact, currentContact, user?.user_id]); - - const handleRemoveUser = async (userId: string) => { - try { - setIsLoading(true); - socket?.emit( - 'remove user from group', - { - group_id: contact?.conversation_id, - user_id: userId, - }, - (response: { status: 'ok' | 'error'; message: string }) => { - if (response.status == 'ok') { - setIsLoading(false); - } else { - setIsLoading(false); - console.error( - 'Failed to remove user from group: ', - response.message, - ); - } - }, - ); - // setParticipants((prevMembers) => - // prevMembers.filter((member) => member.user_id !== userId), - // ); - } catch (error) { - console.error('Failed to remove user:', error); - } - }; + }, [socket, currentContact, currentContact, user?.user_id]); const ParticipantsList = participants?.map( (participant: ParticipantsProps) => ( @@ -163,6 +217,11 @@ function ParticipantsBar({
  • {participant.username} + {participant.isowner ? ( + + + + ) : null} {participant.isadmin && ( @@ -174,21 +233,39 @@ function ParticipantsBar({
  • - {isGroupAdmin && user.user_id !== participant.user_id && ( + {user.user_id !== participant.user_id && isGroupAdmin ? ( handleRemoveUser(participant.user_id)} > - {isLoading ? :

    Remove from group

    } +

    Remove from group

    + {!participant.isadmin ? ( + handleAddToAdministrators(participant.user_id)} + > +

    Add to group administrator

    +
    + ) : null} + {participant.isadmin ? ( + + handleRemoveFromAdministrators(participant.user_id) + } + > +

    Remove from administrator

    +
    + ) : null}
    - )} + ) : null} ), ); - if (!contact) { + if (!currentContact) { return null; } diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 5be5059..8c35d3f 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -238,7 +238,6 @@ function Chat() { {currentContact?.type == 'group' ? (
    diff --git a/server/db/db.js b/server/db/db.js index 9a2be3c..b16361b 100644 --- a/server/db/db.js +++ b/server/db/db.js @@ -112,54 +112,53 @@ async function createTables() { // Create GroupAdmins Table with Trigger, referencing UUID await client.query(` - -- Create the base table - CREATE TABLE IF NOT EXISTS GroupAdmins ( - conversation_id UUID NOT NULL REFERENCES Conversations(conversation_id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES Accounts(user_id) ON DELETE CASCADE, - granted_by UUID NOT NULL REFERENCES Accounts(user_id) ON DELETE CASCADE, - granted_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (conversation_id, user_id) - ); + -- Create the GroupAdmins table with owner flag + CREATE TABLE IF NOT EXISTS GroupAdmins ( + conversation_id UUID NOT NULL REFERENCES Conversations(conversation_id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES Accounts(user_id) ON DELETE CASCADE, + granted_by UUID NOT NULL REFERENCES Accounts(user_id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ DEFAULT NOW(), + is_owner BOOLEAN DEFAULT FALSE, + PRIMARY KEY (conversation_id, user_id), + -- Ensure only one owner per group + CONSTRAINT single_owner EXCLUDE USING btree (conversation_id WITH =) + WHERE (is_owner = true) + ); - -- Create indexes - CREATE INDEX IF NOT EXISTS idx_group_admins_conversation_id ON GroupAdmins (conversation_id); - CREATE INDEX IF NOT EXISTS idx_group_admins_user_id ON GroupAdmins (user_id); + -- Create indexes + CREATE INDEX IF NOT EXISTS idx_group_admins_conversation_id ON GroupAdmins (conversation_id); + CREATE INDEX IF NOT EXISTS idx_group_admins_user_id ON GroupAdmins (user_id); + CREATE INDEX IF NOT EXISTS idx_group_admins_owner ON GroupAdmins (conversation_id) WHERE is_owner = true; - -- Create the validation function - CREATE OR REPLACE FUNCTION validate_admin_grant() - RETURNS TRIGGER AS $$ - BEGIN - -- Allow self-grant for the first admin of a conversation - IF EXISTS ( - SELECT 1 FROM GroupAdmins - WHERE conversation_id = NEW.conversation_id - ) THEN - -- For subsequent admins, verify that the granter is an admin - IF NOT EXISTS ( - SELECT 1 FROM GroupAdmins - WHERE conversation_id = NEW.conversation_id - AND user_id = NEW.granted_by - ) THEN - RAISE EXCEPTION 'Only existing admins can grant admin privileges'; - END IF; - ELSE - -- For the first admin, only allow self-grant - IF NEW.granted_by != NEW.user_id THEN - RAISE EXCEPTION 'First admin must be self-granted'; - END IF; - END IF; - - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - -- Create the trigger - DROP TRIGGER IF EXISTS validate_admin_grant_trigger ON GroupAdmins; - CREATE TRIGGER validate_admin_grant_trigger - BEFORE INSERT OR UPDATE ON GroupAdmins - FOR EACH ROW - EXECUTE FUNCTION validate_admin_grant(); - `); + -- Create the validation function + CREATE OR REPLACE FUNCTION validate_admin_grant() + RETURNS TRIGGER AS $$ + BEGIN + -- Check if this is the first admin (owner) of the group + IF NOT EXISTS ( + SELECT 1 FROM GroupAdmins + WHERE conversation_id = NEW.conversation_id + ) THEN + -- First admin must be self-granted and marked as owner + IF NEW.granted_by != NEW.user_id THEN + RAISE EXCEPTION 'First admin must be self-granted'; + END IF; + NEW.is_owner := true; + ELSE + -- For non-owner admins, verify that the granter is an admin + IF NOT EXISTS ( + SELECT 1 FROM GroupAdmins + WHERE conversation_id = NEW.conversation_id + AND user_id = NEW.granted_by + ) THEN + RAISE EXCEPTION 'Only existing admins can grant admin privileges'; + END IF; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `); console.log("Successfully created GroupAdmins table with trigger"); } catch (e) { console.error("Failed to create tables: ", e); @@ -252,8 +251,8 @@ async function createGroup(user_id, groupName) { `; const insertGroupAdminQuery = ` - INSERT INTO GroupAdmins (conversation_id, user_id, granted_by, granted_at) - VALUES ($1, $2, $3, NOW()) + INSERT INTO GroupAdmins (conversation_id, user_id, granted_by, is_owner) + VALUES ($1, $2, $3, true) RETURNING granted_at; `; @@ -263,6 +262,7 @@ async function createGroup(user_id, groupName) { ]); const group_id = createConversation.rows[0].group_id; + // Make the creator the owner and first admin const insertGroupAdmin = await client.query(insertGroupAdminQuery, [ group_id, user_id, @@ -271,11 +271,6 @@ async function createGroup(user_id, groupName) { if (insertGroupAdmin.rowCount > 0) { const contact_user_id = await addMemberToGroupById(group_id, user_id); - // if (errorMessage) { - // console.error("You are not an admin of the conversation"); - // return errorMessage; - // } - console.log("create group: ", group_id, contact_user_id); insertContactById(user_id, group_id, true); return { group_id, contact_user_id }; } @@ -857,7 +852,8 @@ async function getMembers(conversation_id) { CASE WHEN ga.user_id IS NOT NULL THEN TRUE ELSE FALSE - END AS isAdmin + END AS isAdmin, + COALESCE(ga.is_owner, FALSE) AS isOwner FROM Memberships m JOIN Accounts a ON m.user_id = a.user_id LEFT JOIN GroupAdmins ga ON m.user_id = ga.user_id AND m.conversation_id = ga.conversation_id @@ -925,45 +921,34 @@ async function removeUserFromGroupById(conversation_id, user_id) { WHERE conversation_id = $1 AND user_id = $2; `; - // const removeConversationContactQuery = ` - // DELETE FROM Contacts - // WHERE conversation_id = $1 AND user_id = $2; - // `; - try { - // First, remove the user from the Memberships table + // Check if the user being removed is the owner + const isOwner = await isGroupOwner(user_id, conversation_id); + if (isOwner) { + return { message: "Cannot remove the group owner" }; + } + const removeMembershipResult = await client.query( removeUserFromGroupQuery, [conversation_id, user_id], ); if (removeMembershipResult.rowCount === 0) { - console.log( - `No membership found for user_id: ${user_id} in conversation_id: ${conversation_id}`, - ); return { message: `No membership found for user_id: ${user_id} in conversation_id: ${conversation_id}`, }; } - // Then, remove the user from the Contacts table - // const removeContactResult = await client.query(removeConversationContactQuery, [ - // conversation_id, - // user_id, - // ]); - // - // if (removeContactResult.rowCount === 0) { - // console.log( - // `No contact found for user_id: ${user_id} in conversation_id: ${conversation_id}`, - // ); - // return { - // message: `No contact found for user_id: ${user_id} in conversation_id: ${conversation_id}`, - // }; - // } - - console.log( - `Successfully removed user_id: ${user_id} from conversation_id: ${conversation_id}`, + // Also remove from GroupAdmins if they were an admin + await client.query( + ` + DELETE FROM GroupAdmins + WHERE conversation_id = $1 AND user_id = $2; + `, + [conversation_id, user_id], ); + + return null; } catch (e) { console.error("Failed to remove user from group ", e); return { @@ -995,6 +980,114 @@ async function isConversationMember(user_id, conversation_id) { } } +async function addAdministrator(conversation_id, user_id, granted_by) { + const checkMembershipQuery = ` + SELECT 1 FROM Memberships + WHERE conversation_id = $1 AND user_id = $2 + LIMIT 1; + `; + + const addAdminQuery = ` + INSERT INTO GroupAdmins (conversation_id, user_id, granted_by, is_owner) + VALUES ($1, $2, $3, false) + RETURNING granted_at; + `; + + try { + // Check if the granter is the owner + const isOwner = await isGroupOwner(granted_by, conversation_id); + if (!isOwner) { + console.error("Only the group owner can add administrators"); + return { message: "Only the group owner can add administrators" }; + } + + // Check if user is a member + const membershipCheck = await client.query(checkMembershipQuery, [ + conversation_id, + user_id, + ]); + if (membershipCheck.rows.length === 0) { + console.error("User is not a member of the conversation"); + return { message: "User is not a member of this conversation" }; + } + + const result = await client.query(addAdminQuery, [ + conversation_id, + user_id, + granted_by, + ]); + console.log("User added as admin successfully"); + return result.rows[0].granted_at; + } catch (e) { + console.error("Failed to add administrator ", e); + return { message: "Failed to add administrator" }; + } +} + +async function removeAdministrator(conversation_id, user_id, removed_by) { + const removeAdminQuery = ` + DELETE FROM GroupAdmins + WHERE conversation_id = $1 + AND user_id = $2 + RETURNING user_id; + `; + + const checkMembershipQuery = ` + SELECT 1 FROM Memberships + WHERE conversation_id = $1 AND user_id = $2 + LIMIT 1; + `; + + try { + const isOwner = await isGroupOwner(removed_by, conversation_id); + if (!isOwner) { + console.error("Only the group owner can remove administrators"); + return { message: "Only the group owner can remove administrators" }; + } + + const membershipCheck = await client.query(checkMembershipQuery, [ + conversation_id, + user_id, + ]); + if (membershipCheck.rows.length === 0) { + console.error("User is not a member of the conversation"); + return { message: "User is not a member of this group" }; + } + + const removeAdminResult = await client.query(removeAdminQuery, [ + conversation_id, + user_id, + ]); + if (removeAdminResult.rows.length > 0) { + return null; + } else { + return { message: "User is not an administrator of this group" }; + } + } catch (e) { + console.error("Failed to remove administrator ", e); + return { message: "Failed to remove administrator" }; + } +} + +async function isGroupOwner(user_id, conversation_id) { + const query = ` + SELECT EXISTS ( + SELECT 1 FROM GroupAdmins + WHERE conversation_id = $1 + AND user_id = $2 + AND is_owner = true + ) AS is_owner; + `; + + try { + const result = await client.query(query, [conversation_id, user_id]); + return result.rows[0].is_owner; + } catch (e) { + console.error("Failed to check owner status", e); + return false; + } +} + module.exports = { client, insertUser, @@ -1017,4 +1110,6 @@ module.exports = { removeUserFromGroupById, isConversationMember, isAdmin, + addAdministrator, + removeAdministrator, }; diff --git a/server/socket/socket.js b/server/socket/socket.js index 4f70c58..f2fcc1e 100644 --- a/server/socket/socket.js +++ b/server/socket/socket.js @@ -5,11 +5,12 @@ const { deleteMessage, removeUserFromGroupById, isConversationMember, + isAdmin, + addAdministrator, + removeAdministrator, } = require("../db/db"); const { isValidUsername } = require("../utils/filter"); const { verifyJwtToken } = require("../auth/jwt"); -const console = require("node:console"); -const { call } = require("express"); function initializeSocket(io) { io.use((socket, next) => { @@ -226,11 +227,11 @@ function initializeSocket(io) { socket.on("remove user from group", async (msg, callback) => { const { group_id, user_id } = msg; - if (!group_id || !user_id) { - return callback({ - status: "error", - message: "Missing required parameters", - }); + if (!group_id) { + return callback({ status: "error", message: "No group id provided" }); + } + if (!user_id) { + return callback({ status: "error", message: "No user id provided" }); } try { @@ -242,7 +243,7 @@ function initializeSocket(io) { // Get all sockets in the room const socketsInRoom = await io.in(group_id).fetchSockets(); - + // Remove user from room for (const socketInstance of socketsInRoom) { if (socketInstance.user_id === user_id) { socketInstance.leave(group_id); @@ -253,7 +254,9 @@ function initializeSocket(io) { group_id, user_id, }); - + console.log( + `Successfully removed user: ${user_id}, from group: ${group_id}`, + ); return callback({ status: "ok", message: "Successfully removed user from group", @@ -267,6 +270,75 @@ function initializeSocket(io) { } }); + socket.on("added administrator", async (msg, callback) => { + const { group_id, user_id } = msg; + if (!group_id) { + return callback({ + status: "error", + message: "No conversation id provided", + }); + } + if (!user_id) { + return callback({ status: "error", message: "No user id provided" }); + } + const isUserAdmin = await isAdmin(socket.user_id, group_id); + if (!isUserAdmin) { + return callback({ + status: "error", + message: "You is not an administrator", + }); + } + const result = await addAdministrator(group_id, user_id, socket.user_id); + if (result?.message) { + return callback({ status: "error", message: result.message }); + } + console.log( + `Successfully added user: ${user_id} to administrators to group: ${group_id}`, + ); + io.to(group_id).emit("added administrator", { + group_id, + user_id, + }); + + return callback({ + status: "ok", + message: "Successfully added administrator", + }); + }); + + socket.on("removed administrator", async (msg, callback) => { + const { group_id, user_id } = msg; + if (!group_id) { + return callback({ + status: "error", + message: "No conversation id provided", + }); + } + if (!user_id) { + return callback({ status: "error", message: "No user id provided" }); + } + + const result = await removeAdministrator( + group_id, + user_id, + socket.user_id, + ); + if (result?.message) { + return callback({ status: "error", message: result.message }); + } + console.log( + `Successfully removed user: ${user_id} from administrators in group: ${group_id}`, + ); + io.to(group_id).emit("removed administrator", { + group_id, + user_id, + }); + return callback({ + status: "ok", + message: "Successfully removed administrator", + }); + }); + socket.on("disconnect", (reason) => { console.log("(socket)", socket.id, " disconnected due to: ", reason); });