diff --git a/backend/src/entities/GroupMember.ts b/backend/src/entities/GroupMember.ts index b37a0c1..a45830b 100644 --- a/backend/src/entities/GroupMember.ts +++ b/backend/src/entities/GroupMember.ts @@ -43,9 +43,13 @@ export class GroupMember extends BaseEntity { @Field() lastTempstampVu: Date; + @Field(() => User) @ManyToOne( () => User, (user) => user.groupMember, + { + onDelete: "CASCADE", + }, ) @JoinColumn({ name: "userId" }) user: User; @@ -53,6 +57,9 @@ export class GroupMember extends BaseEntity { @ManyToOne( () => Group, (group) => group.groupMember, + { + onDelete: "CASCADE", + }, ) @JoinColumn({ name: "groupId" }) group: Group; diff --git a/backend/src/entities/Message.ts b/backend/src/entities/Message.ts index 9f4ac6d..fa398ee 100644 --- a/backend/src/entities/Message.ts +++ b/backend/src/entities/Message.ts @@ -51,6 +51,9 @@ export class Message extends BaseEntity { @ManyToOne( () => Group, (group) => group.messages, + { + onDelete: "CASCADE", + }, ) @Field(() => Group) group: Group; diff --git a/backend/src/resolvers/GroupResolver.ts b/backend/src/resolvers/GroupResolver.ts index 7851212..8e5c938 100644 --- a/backend/src/resolvers/GroupResolver.ts +++ b/backend/src/resolvers/GroupResolver.ts @@ -12,12 +12,14 @@ import { Root, UseMiddleware, } from "type-graphql"; + import Group from "../entities/Group"; import { GroupMember } from "../entities/GroupMember"; import { Message } from "../entities/Message"; import User from "../entities/User"; import { getVariableEnv } from "../lib/envManager/envManager"; import { RoleMiddleware } from "../middleware/RoleMiddleware"; +import { addMembersToGroup, removeMembersFromGroup } from "../services/groupMemberService"; import type { ContextType } from "../types/context"; @InputType() @@ -49,6 +51,36 @@ class AddFundsInput { amount!: number; } +@InputType() +class UpdateGroupInput { + @Field() + name!: string; + + @Field() + event_type!: string; + + @Field() + piggy_bank!: number; + + @Field() + deadline!: Date; + + @Field(() => [String], { nullable: true }) + users?: string[]; + + @Field({ nullable: true }) + user_beneficiary?: string; +} + +@InputType() +class RemoveMembersInput { + @Field(() => [Number], { nullable: true }) + userIds?: number[]; + + @Field(() => [String], { nullable: true }) + userEmails?: string[]; +} + @ObjectType() export class MyGroupsResponse { @Field(() => [Group]) @@ -72,7 +104,13 @@ export default class GroupResolver { user: { id: ctx.user?.id }, }, }, - relations: { groupMember: true, user_admin: true, user_beneficiary: true }, + relations: { + groupMember: { + user: true, // 👈 THIS is the key + }, + user_admin: true, + user_beneficiary: true, + }, order: { id: "DESC" }, }); @@ -83,10 +121,27 @@ export default class GroupResolver { return { groups, groupToken }; } + @Query(() => Group) + async getGroupById(@Arg("id") id: number) { + const group = await Group.findOne({ + where: { id: id }, + relations: { + user_admin: true, + user_beneficiary: true, + groupMember: { + user: true, + }, + }, + }); + if (!group) throw new Error("Groupe non trouvĂ©"); + return group; + } + @FieldResolver(() => [GroupMember]) async groupMember(@Root() group: Group) { const groupMembers = await GroupMember.find({ where: { groupId: group.id }, + relations: { user: true }, }); return groupMembers || []; // >>> not null @@ -195,4 +250,149 @@ export default class GroupResolver { return group; } + + @UseMiddleware(RoleMiddleware()) + @Mutation(() => Group) + async updateGroup(@Arg("id") id: number, @Arg("data") data: UpdateGroupInput) { + const group = await Group.findOne({ where: { id: id } }); + if (!group) throw new Error("Groupe non trouvĂ©"); + group.name = data.name; + group.event_type = data.event_type; + group.piggy_bank = data.piggy_bank; + group.deadline = data.deadline; + await group.save(); + + if (data.users?.length) { + await addMembersToGroup({ + userEmails: data.users, + groupId: group.id, + }); + } + + return group; + } + + @UseMiddleware(RoleMiddleware()) + @Mutation(() => String) + async deleteGroup(@Arg("id") id: number, @Ctx() ctx: ContextType): Promise { + if (!ctx.user) { + throw new Error("Utilisateur non connectĂ©"); + } + + const group = await Group.findOne({ + where: { + id, + user_admin: { id: ctx.user.id }, + }, + relations: { user_admin: true }, + }); + + if (!group) { + throw new Error("Groupe introuvable ou accĂšs refusĂ©"); + } + + if (!group.user_admin || group.user_admin.id !== ctx.user.id) { + throw new Error("Il faut ĂȘtre administrateur du groupe pour pouvoir le supprimer"); + } + + try { + await Group.remove(group); + return "Le groupe a Ă©tĂ© supprimĂ©"; + } catch (err) { + console.error("deleteGroup error:", err); + throw new Error("Une erreur est survenue lors de la suppression du groupe"); + } + } + + @UseMiddleware(RoleMiddleware()) + @Mutation(() => String) + async removeMembersFromGroup( + @Arg("groupId", () => Number) groupId: number, + @Arg("data") data: RemoveMembersInput, + @Ctx() ctx: ContextType, + ): Promise { + if (!ctx.user) { + throw new Error("Utilisateur non connectĂ©"); + } + + // Ensure at least one input is provided + if ((!data.userIds || data.userIds.length === 0) && (!data.userEmails || data.userEmails.length === 0)) { + throw new Error("Vous devez fournir au moins un identifiant utilisateur ou un email"); + } + + const group = await Group.findOne({ + where: { + id: groupId, + }, + relations: { user_admin: true }, + }); + + if (!group) { + throw new Error("Groupe introuvable ou accĂšs refusĂ©"); + } + + const currentUserId = ctx.user.id; + const isAdmin = ctx.user.id === group.user_admin.id; + + // Convert emails to user IDs if provided + let userIdsToRemove: number[] = []; + + if (data.userEmails && data.userEmails.length > 0) { + await Promise.all( + data.userEmails.map(async (email) => { + const user = await User.findOne({ where: { email } }); + if (user) { + userIdsToRemove.push(user.id); + } + }), + ); + } + + // Add direct user IDs if provided + if (data.userIds && data.userIds.length > 0) { + userIdsToRemove.push(...data.userIds); + } + + // Remove duplicates + userIdsToRemove = [...new Set(userIdsToRemove)]; + + if (userIdsToRemove.length === 0) { + throw new Error("Aucun utilisateur valide trouvĂ©"); + } + + // Admin cannot remove itself from a group + if (isAdmin && userIdsToRemove.includes(group.user_admin.id)) { + throw new Error("L'administrateur du groupe ne peut pas ĂȘtre supprimĂ©. Supprimez plutĂŽt le groupe"); + } + + // Remove myself from a group + if (!isAdmin && userIdsToRemove.length === 1 && userIdsToRemove[0] === currentUserId) { + try { + await removeMembersFromGroup({ + userIds: [currentUserId], + groupId, + }); + return "SuccĂšs! Vous ne faites plus partie du groupe!"; + } catch (err) { + console.error("removeMembersFromGroup error:", err); + throw new Error("Une erreur est survenue, nous n'avons pas pu vous supprimer du groupe"); + } + } + + // Admin removes users from a group + if (isAdmin && userIdsToRemove.length > 0 && !userIdsToRemove.includes(group.user_admin.id)) { + try { + await removeMembersFromGroup({ + userIds: userIdsToRemove, + groupId, + }); + return "SuccĂšs! Les utilisateurs ont Ă©tĂ© supprimĂ©s du groupe!"; + } catch (err) { + console.error("removeMembersFromGroup error:", err); + throw new Error("Une erreur est survenue, nous n'avons pas pu supprimer les utilisateurs du groupe"); + } + } + + throw new Error("Vous n'avez pas les permissions nĂ©cessaires pour effectuer cette action"); + } } diff --git a/backend/src/resolvers/MessageResolver.ts b/backend/src/resolvers/MessageResolver.ts index fc082da..f8a6fbc 100644 --- a/backend/src/resolvers/MessageResolver.ts +++ b/backend/src/resolvers/MessageResolver.ts @@ -131,7 +131,7 @@ export default class MessageResolver { if (!userId) throw new Error("Utilisateur non authentifiĂ©"); - const group = await GroupMember.findOne({ where: { user: { id: userId }, group: { id: groupId } } }); + const group = await GroupMember.findOne({ where: { userId: userId, groupId: groupId } }); // verifier que l'utilisateur fait bien partie du groupe if (!group) throw new Error("Groupe non trouvĂ©"); diff --git a/backend/src/services/groupMemberService.ts b/backend/src/services/groupMemberService.ts index 22aff8b..1dc99c6 100644 --- a/backend/src/services/groupMemberService.ts +++ b/backend/src/services/groupMemberService.ts @@ -7,6 +7,11 @@ type AddMembersInput = { groupId: number; }; +type RemoveMembersInput = { + userIds: number[]; + groupId: number; +}; + /** * Add users to a group from a list of emails. * - Silently skips emails that do not match a user. @@ -17,7 +22,8 @@ export async function addMembersToGroup({ userEmails, groupId }: AddMembersInput await Promise.all( userEmails.map(async (userEmail) => { const userToAdd = await User.findOne({ where: { email: userEmail } }); - if (!userToAdd) { + const userPending = await PendingInvitation.findOne({ where: { userEmail: userEmail } }); + if (!userToAdd && !userPending) { // if the user does not exist in the db, add it to the pendinginvitationList const pendingInvitation = PendingInvitation.create({ userEmail: userEmail, @@ -29,7 +35,7 @@ export async function addMembersToGroup({ userEmails, groupId }: AddMembersInput } const groupMember = GroupMember.create({ - userId: userToAdd.id, + userId: userToAdd?.id, groupId, }); @@ -37,3 +43,25 @@ export async function addMembersToGroup({ userEmails, groupId }: AddMembersInput }), ); } + +export async function removeMembersFromGroup({ userIds, groupId }: RemoveMembersInput) { + if (!userIds || userIds.length === 0) return; + + // Remove all group members + await Promise.all( + userIds.map(async (userId) => { + const groupMember = await GroupMember.findOne({ + where: { userId: userId, groupId: groupId }, + }); + + if (!groupMember) return; + + try { + await GroupMember.remove(groupMember); + } catch (err) { + console.error("removeMembersFromGroup error:", err); + throw new Error("Une erreur est survenue lors de la suppression de l'utilisateur du groupe"); + } + }), + ); +} diff --git a/frontend/src/components/forms/CreateGroupForm.tsx b/frontend/src/components/forms/CreateGroupForm.tsx deleted file mode 100644 index d9f05f9..0000000 --- a/frontend/src/components/forms/CreateGroupForm.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import type React from "react"; -import { useState } from "react"; -import { useCreateGroupMutation } from "../../graphql/generated/graphql-types"; -import { GET_ALL_MY_GROUPS } from "../../graphql/operations/groupOperations"; -import { groupCreationFormValidation } from "../../hooks/formValidationRules"; -import { useSanitizedForm } from "../../hooks/useSanitizedForm"; -import Icon from "../utils/Icon"; -import Input from "../utils/Input"; -import InputWithToggle from "../utils/InputWithToggle"; -import ResponsiveImage from "../utils/ResponsiveImage"; -import SearchSelectInput from "../utils/SearchSelectInput"; -import Subtitle from "../utils/Subtitle"; -import GroupLink from "./GroupLink"; - -type CreateGroupFormProps = { - onSuccess?: () => void; - onCancel?: () => void; -}; - -export default function CreateGroupForm({ onSuccess, onCancel }: CreateGroupFormProps) { - const options = [ - { - label: "Anniversaire", - value: "Anniversaire", - }, - { - label: "Marriage", - value: "Marriage", - }, - { - label: "Naissance", - value: "Naissance", - }, - { - label: "Pot de dĂ©part", - value: "Pot de dĂ©part", - }, - { - label: "NoĂ«l", - value: "NoĂ«l", - }, - ]; - - // const [query, setQuery] = useState(""); - const [checked, setChecked] = useState(false); - - const { formData, handleChange, getSanitizedData, errors, isValid, setFormData, isEmpty } = - useSanitizedForm( - { - name: "", - event_type: "", - piggy_bank: 0, - deadline: "", - users: [] as string[], - user_beneficiary: "", - }, - groupCreationFormValidation, - ); - - const [error, setError] = useState(""); - - const [createGroup] = useCreateGroupMutation({ - awaitRefetchQueries: true, - refetchQueries: [ - { - query: GET_ALL_MY_GROUPS, - }, - ], - }); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (isEmpty) { - console.info("Form is empty."); - setError("Être bref c'est bien, mais il faut quand mĂȘme remplir le formulaire"); - return; - } - if (isValid) { - try { - const sanitizedData = getSanitizedData(); - if (!sanitizedData) { - console.error("Sanitized data is null or undefined."); - return; - } - - const response = await createGroup({ - variables: { - data: { - ...sanitizedData, - piggy_bank: Number(sanitizedData?.piggy_bank), - deadline: new Date(sanitizedData?.deadline), - }, - }, - }); - - console.info("Group created successfully:", response.data); - setFormData({ - name: "", - event_type: "", - piggy_bank: 0, - deadline: "", - users: [], - user_beneficiary: "", //Do not reset users here instead show the list of existing users - }); - - setChecked(false); - - // Close the parent modal if provided - if (onSuccess) onSuccess(); - } catch (error: unknown) { - console.error("Error creating group:", error); - if (error instanceof Error) { - setError(error.message); - } else { - setError("Une erreur est survenue"); - } - } - } - - console.error("Form has errors, cannot submit.", errors); - } - - return ( -
-
- {/* Form to create a new group */} - Créer un groupe -
- -
-
- - - - handleChange({ - target: { name: "event_type", value: val }, - } as React.ChangeEvent) - } - placeholder="Quel est l'événement ?" - error={errors.event_type} - icon="gift" - options={options} - theme="light" - /> - - { - setChecked(!checked); - }} - name="user_beneficiary" - value={formData.user_beneficiary} - onChange={handleChange} - label="Le nom du destinataire" - question="Voulez-vous ajouter un destinataire? " - error={errors.user_beneficiary} - /> - - -
-
- - {onCancel && ( - - )} -
- {error &&

{error}

} - {errors.main &&

{errors.main}

} -
- -
-
- {/* Adding users can go here */} -
- - -
- - {/* TO DO: reintegrer le user input - { - setFormData({ ...formData, users: formData.users.filter((user) => user !== email) }); - - }} - onAddTag={(email) => { - setFormData({ ...formData, users: formData.users.filter((user) => user !== email) }); - setQuery(""); - - }} - items={formData.users} - error={errors.users} - /> */} -
-
-
- ); -} diff --git a/frontend/src/components/forms/GroupLink.tsx b/frontend/src/components/forms/GroupLink.tsx deleted file mode 100644 index c245646..0000000 --- a/frontend/src/components/forms/GroupLink.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Input from "../utils/Input"; - -export default function GroupLink() { - // La logique pour récupérer un lien ici - return ( - {}} - /> - ); -} diff --git a/frontend/src/components/auth/AuthFooter.tsx b/frontend/src/components/forms/auth/AuthFooter.tsx similarity index 100% rename from frontend/src/components/auth/AuthFooter.tsx rename to frontend/src/components/forms/auth/AuthFooter.tsx diff --git a/frontend/src/components/auth/AuthFormTemplate.tsx b/frontend/src/components/forms/auth/AuthFormTemplate.tsx similarity index 95% rename from frontend/src/components/auth/AuthFormTemplate.tsx rename to frontend/src/components/forms/auth/AuthFormTemplate.tsx index 1f21098..9a7aaf8 100644 --- a/frontend/src/components/auth/AuthFormTemplate.tsx +++ b/frontend/src/components/forms/auth/AuthFormTemplate.tsx @@ -1,4 +1,4 @@ -import Title from "../utils/Title"; +import Title from "../../utils/Title"; type FormProps = { title: string; diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/forms/auth/LoginForm.tsx similarity index 89% rename from frontend/src/components/auth/LoginForm.tsx rename to frontend/src/components/forms/auth/LoginForm.tsx index 6586f33..eb2667b 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/forms/auth/LoginForm.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; import { useNavigate } from "react-router"; -import { useLoginMutation } from "../../graphql/generated/graphql-types"; -import consoleErrorDev from "../../hooks/erreurMod"; -import { useMyProfileStore } from "../../zustand/myProfileStore"; -import Button from "../utils/Button"; -import Input from "../utils/Input"; +import { useLoginMutation } from "../../../graphql/generated/graphql-types"; +import consoleErrorDev from "../../../hooks/erreurMod"; +import { useMyProfileStore } from "../../../zustand/myProfileStore"; +import Button from "../../utils/Button"; +import Input from "../../utils/Input"; import AuthFooter from "./AuthFooter"; import AuthFormTemplate from "./AuthFormTemplate"; diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/forms/auth/RegisterForm.tsx similarity index 95% rename from frontend/src/components/auth/RegisterForm.tsx rename to frontend/src/components/forms/auth/RegisterForm.tsx index a2e1397..51def5c 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/forms/auth/RegisterForm.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; import { useNavigate } from "react-router"; -import { useSignupMutation } from "../../graphql/generated/graphql-types"; -import consoleErrorDev from "../../hooks/erreurMod"; -import { useMyProfileStore } from "../../zustand/myProfileStore"; -import Button from "../utils/Button"; -import Input from "../utils/Input"; +import { useSignupMutation } from "../../../graphql/generated/graphql-types"; +import consoleErrorDev from "../../../hooks/erreurMod"; +import { useMyProfileStore } from "../../../zustand/myProfileStore"; +import Button from "../../utils/Button"; +import Input from "../../utils/Input"; import AuthFooter from "./AuthFooter"; import AuthFormTemplate from "./AuthFormTemplate"; diff --git a/frontend/src/components/auth/auth.css b/frontend/src/components/forms/auth/auth.css similarity index 100% rename from frontend/src/components/auth/auth.css rename to frontend/src/components/forms/auth/auth.css diff --git a/frontend/src/components/forms/groups/GroupForm.tsx b/frontend/src/components/forms/groups/GroupForm.tsx new file mode 100644 index 0000000..e6e4e48 --- /dev/null +++ b/frontend/src/components/forms/groups/GroupForm.tsx @@ -0,0 +1,105 @@ +import type React from "react"; +import Icon from "../../utils/Icon"; +import Input from "../../utils/Input"; +import InputWithToggle from "../../utils/InputWithToggle"; +import SearchSelectInput from "../../utils/SearchSelectInput"; +import Subtitle from "../../utils/Subtitle"; +import { options } from "../groups/eventOptions.ts"; + +type GroupFormProps = { + onSuccess?: () => void; + /** When provided, the form works in edit mode and pre-populates with the group data */ + groupId?: number; + formData: any; + errors: any; + isEdit: boolean; + handleChange: any; + checked: boolean; + setChecked: any; + isAdmin: boolean; +}; + +export default function GroupForm({ + formData, + errors, + checked, + setChecked, + isEdit, + handleChange, + isAdmin, +}: GroupFormProps) { + const disabled = isEdit && !isAdmin; + + return ( + <> + + {isEdit ? "Modifier le groupe" : "Créer un groupe"} + +
+ +
+
+ + + + handleChange({ + target: { name: "event_type", value: val }, + } as React.ChangeEvent) + } + placeholder="Quel est l'événement ?" + error={errors.event_type} + icon="gift" + options={options} + theme="light" + /> + + + + { + setChecked(!checked); + }} + name="user_beneficiary" + value={formData.user_beneficiary ?? ""} + onChange={handleChange} + label="Le nom du destinataire" + question="Voulez-vous ajouter un destinataire? " + error={errors.user_beneficiary} + /> + + +
+ + ); +} diff --git a/frontend/src/components/forms/groups/GroupFormTemplate.tsx b/frontend/src/components/forms/groups/GroupFormTemplate.tsx new file mode 100644 index 0000000..5b82852 --- /dev/null +++ b/frontend/src/components/forms/groups/GroupFormTemplate.tsx @@ -0,0 +1,57 @@ +import type React from "react"; +import Button from "../../utils/Button"; + +type GroupFormTemplateProps = { + onSubmit: (e: React.FormEvent) => void; + left: React.ReactNode; + right?: React.ReactNode; + isEdit: boolean; + submitError: any; + errors: any; + onSuccess: () => void; + isAdmin: boolean; +}; + +export default function GroupFormTemplate({ + onSubmit, + onSuccess, + left, + right, + isEdit, + submitError, + errors, + isAdmin, +}: GroupFormTemplateProps) { + return ( +
{ + onSubmit(e); + onSuccess(); + }} + autoComplete="off" + > +
+ {left} +
+ +
{right}
+
+ {isEdit && isAdmin && ( + + )} + + {!isEdit && ( + + )} + + {submitError &&

{submitError}

} + {errors.main &&

{errors.main}

} +
+
+ ); +} diff --git a/frontend/src/components/forms/groups/UsersForm.tsx b/frontend/src/components/forms/groups/UsersForm.tsx new file mode 100644 index 0000000..51ef4a2 --- /dev/null +++ b/frontend/src/components/forms/groups/UsersForm.tsx @@ -0,0 +1,106 @@ +import type { Dispatch, SetStateAction } from "react"; +import { + useDeleteGroupMutation, + useRemoveMembersFromGroupMutation, +} from "../../../graphql/generated/graphql-types"; +import Button from "../../utils/Button"; +import SearchInput from "../../utils/SearchInput"; + +type handleUsersFormProps = { + query: string; + setQuery: Dispatch>; + setFormData: (updater: (prev: any) => any) => void; + errors: any; + onAddTag: (email: string) => void; + isEdit: boolean; + isAdmin: boolean; + groupId: number; + currentUser: any; + items: string[]; + onSuccess?: () => void; + onRemoveMember?: (email: string) => void; +}; + +export default function UsersForm({ + query, + setQuery, + onAddTag, + items, + setFormData, + errors, + isAdmin, + isEdit, + groupId, + currentUser, + onSuccess, + onRemoveMember, +}: handleUsersFormProps) { + const [deleteGroup] = useDeleteGroupMutation(); + const [removeMembers] = useRemoveMembersFromGroupMutation(); + + async function leaveGroup() { + if (!currentUser?.id) return; + try { + await removeMembers({ + variables: { + groupId: groupId, + data: { + userIds: [Number(currentUser.id)], + }, + }, + }); + if (onSuccess) onSuccess(); + } catch (error) { + console.error("Error leaving group:", error); + } + } + + async function deleteMyGroup() { + try { + await deleteGroup({ + variables: { + deleteGroupId: groupId, + }, + }); + if (onSuccess) onSuccess(); + } catch (error) { + console.error("Error deleting group:", error); + } + } + + return ( +
+ setQuery(e.target.value)} // ✅ correct + items={items} + error={errors.users} + onClick={(email: string) => { + setFormData((prev: any) => ({ + ...prev, + users: prev?.users?.filter((user: string) => user !== email), + })); + // Notify parent component about member removal + if (onRemoveMember) { + onRemoveMember(email); + } + }} + onAddTag={onAddTag} + /> + {!isAdmin && isEdit && ( + + )} + {isAdmin && isEdit && ( + + )} +
+ ); +} diff --git a/frontend/src/components/forms/groups/eventOptions.ts b/frontend/src/components/forms/groups/eventOptions.ts new file mode 100644 index 0000000..e8a3846 --- /dev/null +++ b/frontend/src/components/forms/groups/eventOptions.ts @@ -0,0 +1,22 @@ +export const options = [ + { + label: "Anniversaire", + value: "Anniversaire", + }, + { + label: "Marriage", + value: "Marriage", + }, + { + label: "Naissance", + value: "Naissance", + }, + { + label: "Pot de départ", + value: "Pot de départ", + }, + { + label: "Noël", + value: "Noël", + }, +]; diff --git a/frontend/src/hooks/formValidationRules.ts b/frontend/src/components/forms/groups/formValidationRules.ts similarity index 89% rename from frontend/src/hooks/formValidationRules.ts rename to frontend/src/components/forms/groups/formValidationRules.ts index 98c0080..16819d8 100644 --- a/frontend/src/hooks/formValidationRules.ts +++ b/frontend/src/components/forms/groups/formValidationRules.ts @@ -1,6 +1,6 @@ -import type { CreateGroupInput } from "../graphql/generated/graphql-types"; -import { countdownDate } from "../utils/dateCalculator"; -import { verifyEmail } from "./verifyEmail"; +import type { CreateGroupInput } from "../../../graphql/generated/graphql-types"; +import { verifyEmail } from "../../../hooks/verifyEmail"; +import { countdownDate } from "../../../utils/dateCalculator"; export type GroupFormErrors = Partial> & { main?: string; diff --git a/frontend/src/components/forms/groups/index.tsx b/frontend/src/components/forms/groups/index.tsx new file mode 100644 index 0000000..990c1b6 --- /dev/null +++ b/frontend/src/components/forms/groups/index.tsx @@ -0,0 +1,313 @@ +import { useEffect, useState } from "react"; +import { + type CreateGroupInput, + type Group, + useCreateGroupMutation, + useGetGroupByIdQuery, + useRemoveMembersFromGroupMutation, + useUpdateGroupMutation, +} from "../../../graphql/generated/graphql-types"; +import { GET_ALL_MY_GROUPS } from "../../../graphql/operations/groupOperations"; +import { useSanitizedForm } from "../../../hooks/useSanitizedForm"; +import { useUserPermissions } from "../../../hooks/useUserPermissions"; +import { useMyProfileStore } from "../../../zustand/myProfileStore"; +import { type GroupFormErrors, groupCreationFormValidation } from "../groups/formValidationRules"; +import GroupForm from "./GroupForm"; +import GroupFormTemplate from "./GroupFormTemplate"; +import UsersForm from "./UsersForm"; + +type GroupFormIndex = { + onSuccess: () => void; + onCancel: () => void; + groupId?: number; + isOpen?: boolean; +}; + +const EMPTY_FORM_STATE: CreateGroupInput = { + name: "", + event_type: "", + piggy_bank: 0, + deadline: "", + users: [], + user_beneficiary: "", +}; + +export default function GroupFormindex({ onSuccess, groupId }: GroupFormIndex) { + const [submitError, setSubmitError] = useState(""); + const [checked, setChecked] = useState(false); + const [query, setQuery] = useState(""); + const { userProfile } = useMyProfileStore(); + const [group, setGroup] = useState(null); + const { currentUser, isAdmin } = useUserPermissions(group || undefined); + const isEditMode = !!groupId; //(truthy => true or falsy => false) + const [removeMembers] = useRemoveMembersFromGroupMutation({ + awaitRefetchQueries: true, + refetchQueries: [ + { + query: GET_ALL_MY_GROUPS, + }, + ], + }); + const [usersToRemove, setUsersToRemove] = useState([]); + const [originalMemberEmails, setOriginalMemberEmails] = useState([]); + + const { formData, handleChange, getSanitizedData, errors, setErrors, isValid, setFormData, isEmpty } = + useSanitizedForm(EMPTY_FORM_STATE, groupCreationFormValidation); + + const { + data, + loading, + error: queryError, + } = useGetGroupByIdQuery({ + variables: { + // value will be ignored when skip is true + id: groupId ?? 0, + }, + skip: !isEditMode, + }); + + const [createGroup] = useCreateGroupMutation({ + awaitRefetchQueries: true, + refetchQueries: [ + { + query: GET_ALL_MY_GROUPS, + }, + ], + }); + + const [updateGroup] = useUpdateGroupMutation({ + awaitRefetchQueries: true, + refetchQueries: [ + { + query: GET_ALL_MY_GROUPS, + }, + ], + }); + + useEffect(() => { + if (!isEditMode || !data?.getGroupById) return; + const groupData = data.getGroupById; + // Set group state with proper type casting + setGroup(groupData as Group); + + // Extract emails from existing group members (excluding the current user) + const existingMemberEmails = groupData.groupMember + .map((member) => member.user?.email) + .filter((email): email is string => { + // Filter out undefined/null emails and the current user's email + return Boolean(email) && email.toLowerCase() !== currentUser?.email?.toLowerCase(); + }); + + // Store original member emails to track removals + setOriginalMemberEmails(existingMemberEmails); + // Reset users to remove when group data loads + setUsersToRemove([]); + + setFormData((prev) => ({ + ...prev, + name: groupData?.name ?? "", + event_type: groupData?.event_type ?? "", + piggy_bank: groupData?.piggy_bank ?? 0, + // Normalise to YYYY-MM-DD for the date input, if possible + deadline: groupData?.deadline ? new Date(groupData?.deadline).toISOString().slice(0, 10) : "", + // We don't currently have an email here, so use the first name as a display value if present + user_beneficiary: groupData?.user_beneficiary?.firstName ?? "", + users: existingMemberEmails, + })); + setChecked(Boolean(groupData?.user_beneficiary)); + }, [data, isEditMode, currentUser?.email]); //setFromData + + if (isEditMode) { + if (loading) return
Loading...
; + if (queryError) return
Error: {queryError.message}
; + if (!data?.getGroupById) return
Group not found
; + } + + //Handling the users: + + const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); // adapt to your auth source + + const handleAddUserByEmail = (email: string) => { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) return; + + if (!isValidEmail(normalizedEmail)) { + setErrors((prev) => ({ + ...prev, + users: "Adresse email invalide", + })); + return; + } + + if (userProfile?.email && normalizedEmail === currentUser?.email.toLowerCase()) { + setErrors((prev) => ({ + ...prev, + users: "Vous ne pouvez pas vous ajouter vous-mĂȘme", + })); + return; + } + + if (formData.users?.includes(normalizedEmail)) { + setErrors((prev) => ({ + ...prev, + users: "Cet utilisateur est dĂ©jĂ  ajoutĂ©", + })); + return; + } + + setFormData((prev) => ({ + ...prev, + users: [...(prev.users || []), normalizedEmail], + })); + + // If this user was marked for removal but is being added back, remove from removal list + if (isEditMode && isAdmin && usersToRemove.includes(normalizedEmail)) { + setUsersToRemove((prev) => prev.filter((e) => e !== normalizedEmail)); + } + + setErrors((prev) => { + const { ...rest } = prev; + return rest; + }); + setQuery(""); + }; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitError(""); + + if (isEmpty) { + setSubmitError("Être bref c'est bien, mais il faut quand mĂȘme remplir le formulaire"); + return; + } + + if (!isValid) { + console.error("Form has errors, cannot submit.", errors); + return; + } + + try { + const sanitizedData = getSanitizedData(); + + if (!sanitizedData) { + console.error("Sanitized data is null or undefined."); + return; + } + + const commonVariables = { + data: { + ...sanitizedData, + piggy_bank: Number(sanitizedData.piggy_bank), + deadline: new Date(sanitizedData.deadline), + users: formData.users, + }, + }; + + if (isEditMode && groupId) { + await updateGroup({ + variables: { + ...commonVariables, + updateGroupId: groupId, + }, + }); + + // Remove members that were removed from the form + if (usersToRemove.length > 0 && isAdmin) { + await removeMembersAsAdmin(); + } + + // Reset removal tracking after successful update + setUsersToRemove([]); + } else { + const response = await createGroup({ + variables: commonVariables, + }); + + console.info("Group created successfully:", response.data); + + setFormData(EMPTY_FORM_STATE); + setChecked(false); + setUsersToRemove([]); + } + + if (onSuccess) onSuccess(); + } catch (error: unknown) { + console.error("Error submitting group form:", error); + if (error instanceof Error) { + setSubmitError(error.message); + } else { + setSubmitError("Une erreur est survenue"); + } + } + } + + async function removeMembersAsAdmin() { + if (!isAdmin || !groupId || usersToRemove.length === 0) return; + try { + await removeMembers({ + variables: { + groupId: groupId, + data: { + userEmails: usersToRemove, + }, + }, + }); + // Reset the users to remove list after successful removal + setUsersToRemove([]); + } catch (error) { + console.error("Error removing members:", error); + throw error; // Re-throw to be caught by handleSubmit + } + } + + const handleRemoveMember = (email: string) => { + // Only track removal if this email was originally a member + if (isEditMode && isAdmin && originalMemberEmails.includes(email)) { + setUsersToRemove((prev) => { + // Avoid duplicates + if (!prev.includes(email)) { + return [...prev, email]; + } + return prev; + }); + } + }; + return ( + + } + right={ + + } + onSubmit={handleSubmit} + isEdit={isEditMode} + submitError={submitError} + errors={errors} + /> + ); +} diff --git a/frontend/src/components/groups/Groups.tsx b/frontend/src/components/groups/Groups.tsx index b666753..b8542b4 100644 --- a/frontend/src/components/groups/Groups.tsx +++ b/frontend/src/components/groups/Groups.tsx @@ -2,7 +2,7 @@ import type { GetAllMyGroupsQuery } from "../../graphql/generated/graphql-types" import { useToggle } from "../../hooks/useToggle"; import type { Message } from "../../types/Message"; import { formatDate } from "../../utils/dateCalculator"; -import CreateGroupForm from "../forms/CreateGroupForm"; +import GroupFormindex from "../forms/groups/index"; import Button from "../utils/Button"; import Card from "../utils/Card"; import Container from "../utils/Container"; @@ -83,7 +83,7 @@ export default function Groups({ withPadding={false} className="p-0 overflow-y-auto max-h-[85vh] max-md:max-h-full" > - + ); diff --git a/frontend/src/components/groups/Messaging/Messaging.tsx b/frontend/src/components/groups/Messaging/Messaging.tsx index 9f2ec1b..ae553eb 100644 --- a/frontend/src/components/groups/Messaging/Messaging.tsx +++ b/frontend/src/components/groups/Messaging/Messaging.tsx @@ -3,10 +3,13 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { FaArrowDown, FaLocationArrow } from "react-icons/fa"; import type { GetAllMessageMyGroupsQuery } from "../../../graphql/generated/graphql-types.ts"; import { useGetLazyMessagesLazyQuery } from "../../../graphql/generated/graphql-types.ts"; +import { useToggle } from "../../../hooks/useToggle.ts"; import type { Message as MessageType } from "../../../types/Message"; import { countdownDate, isSameDate } from "../../../utils/dateCalculator.ts"; import { useMyProfileStore } from "../../../zustand/myProfileStore.ts"; +import GroupFormIndex from "../../forms/groups/index.tsx"; import Icon from "../../utils/Icon.tsx"; +import Modal from "../../utils/Modal.tsx"; import Subtitle from "../../utils/Subtitle.tsx"; import Message from "./Message.tsx"; import TimeLigne from "./TimeLigne.tsx"; @@ -45,6 +48,7 @@ export default function Messaging({ const id = useId(); const [messageInput, setMessageInput] = useState(""); const { userProfile } = useMyProfileStore(); + const groupFormModal = useToggle(false); // scroll automatique le plus en bas possible const bottomRef = useRef(null); @@ -221,8 +225,20 @@ export default function Messaging({

- +
+ {groupFormModal.isOpen && ( + + + + )} )}
{ placholder?: string; label?: string; icon?: IconTypes; + disabled?: boolean; } export default function Input({ @@ -25,6 +26,7 @@ export default function Input({ className = "", placeholder, icon, + disabled = false, ...props }: InputProps) { const baseStyles = @@ -35,6 +37,12 @@ export default function Input({ ? "border-dark border-[3.5px] text-dark focus:border-blue placeholder:text-dark/50" : "bg-transparent border-white border-[3.5px] text-white placeholder:text-white/70"; + const disabledStyles = disabled + ? theme === "dark" + ? "opacity-60 cursor-not-allowed bg-gray-50 border-gray-300 text-gray-600" + : "opacity-60 cursor-not-allowed bg-gray-900/30 border-gray-500 text-gray-300 placeholder-gray-400" + : ""; + const errorStyles = error ? "border-orange focus:border-orange" : ""; const id = useId(); @@ -52,7 +60,8 @@ export default function Input({ type={type} value={value} onChange={onChange} - className={`${baseStyles} ${themeStyles} ${errorStyles} ${className}`} + disabled={disabled} + className={`${baseStyles} ${themeStyles} ${disabledStyles} ${errorStyles} ${className}`} name={name} placeholder={placeholder} {...props} @@ -60,7 +69,13 @@ export default function Input({ {icon && ( )}
diff --git a/frontend/src/components/utils/InputWithToggle.tsx b/frontend/src/components/utils/InputWithToggle.tsx index f4d1da4..76fd5d5 100644 --- a/frontend/src/components/utils/InputWithToggle.tsx +++ b/frontend/src/components/utils/InputWithToggle.tsx @@ -7,6 +7,7 @@ interface InputToggleProps extends React.ComponentProps { label?: string; question?: string; onChange: (e: React.ChangeEvent) => void; + disabled?: boolean; } export default function InputWithToggle({ @@ -16,11 +17,14 @@ export default function InputWithToggle({ theme = "light", label, question, + disabled = false, ...props }: InputToggleProps) { return (
- {checked && } + {checked && ( + + )} {!checked && {question}}
diff --git a/frontend/src/components/utils/SearchInput.tsx b/frontend/src/components/utils/SearchInput.tsx index c53f577..ef2d53a 100644 --- a/frontend/src/components/utils/SearchInput.tsx +++ b/frontend/src/components/utils/SearchInput.tsx @@ -1,5 +1,6 @@ import { useId } from "react"; import Icon from "./Icon"; +import Input from "./Input"; import Tag from "./Tag"; interface SearchInputProps extends Omit, "onClick"> { @@ -14,6 +15,7 @@ interface SearchInputProps extends Omit void; onAddTag?: (tag: string) => void; items: string[]; + disabled?: boolean; } export default function SearchInput({ @@ -29,6 +31,7 @@ export default function SearchInput({ type = "text", error, className, + disabled = false, ...props }: SearchInputProps) { const baseStyles = @@ -44,12 +47,14 @@ export default function SearchInput({ const id = useId(); function handleAddTag() { + if (disabled) return; if (value.trim() !== "" && onAddTag) { onAddTag(value.trim()); } } function handleKeyDown(e: React.KeyboardEvent) { + if (disabled) return; if (e.key === "Enter" && value.trim() !== "") { e.preventDefault(); handleAddTag(); @@ -64,29 +69,43 @@ export default function SearchInput({ )}
- -
{error &&

{error}

}
{items.map((item) => ( - onClick(item)} /> + !disabled && onClick(item)} + /> ))}
diff --git a/frontend/src/components/utils/SearchSelectInput.tsx b/frontend/src/components/utils/SearchSelectInput.tsx index b445277..9957f43 100644 --- a/frontend/src/components/utils/SearchSelectInput.tsx +++ b/frontend/src/components/utils/SearchSelectInput.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, useRef, useState } from "react"; import type { IconTypes } from "./Icon"; import Icon from "./Icon"; +import Input from "./Input"; interface Option { label: string; @@ -18,6 +19,7 @@ interface SearchableSelectProps { theme?: "light" | "dark"; icon?: IconTypes; className?: string; + disabled?: boolean; } export default function SearchSelectInput({ @@ -29,8 +31,10 @@ export default function SearchSelectInput({ label, error, theme = "light", + disabled = false, icon, className = "", + name, }: SearchableSelectProps) { const id = useId(); const [open, setOpen] = useState(false); @@ -41,8 +45,17 @@ export default function SearchSelectInput({ // Filter options const filtered = options.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase())); + // Close dropdown if disabled + useEffect(() => { + if (disabled && open) { + setOpen(false); + setQuery(""); + } + }, [disabled, open]); + // Close dropdown on click outside useEffect(() => { + if (disabled) return; const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); @@ -51,11 +64,11 @@ export default function SearchSelectInput({ }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); - }, []); + }, [disabled]); // Keyboard navigation function handleKeyDown(e: React.KeyboardEvent) { - if (!open) return; + if (disabled || !open) return; if (e.key === "ArrowDown") { e.preventDefault(); @@ -84,6 +97,12 @@ export default function SearchSelectInput({ ? "border-dark border-[3.5px] text-dark focus:border-blue bg-white placeholder:text-dark/50" : "bg-transparent border-white border-[3.5px] text-white placeholder:text-white/70"; + const disabledStyles = disabled + ? theme === "dark" + ? "opacity-60 cursor-not-allowed bg-gray-50 border-gray-300 text-gray-600" + : "opacity-60 cursor-not-allowed bg-gray-900/30 border-gray-500 text-gray-300" + : "cursor-pointer"; + const errorInput = error ? "border-orange focus:border-orange" : ""; const dropdownStyles = @@ -91,11 +110,12 @@ export default function SearchSelectInput({ return (
@@ -110,13 +130,15 @@ export default function SearchSelectInput({ {/* WHEN CLOSED → show regular input */} {!open && (
setOpen(true)} + aria-disabled={disabled} + tabIndex={disabled ? -1 : 0} + onClick={() => !disabled && setOpen(true)} onKeyDown={(e) => { + if (disabled) return; if (e.key === "Enter") { e.preventDefault(); setOpen(true); @@ -127,13 +149,26 @@ export default function SearchSelectInput({ {value ? options.find((o) => o.value === value)?.label : placeholder} - {icon && } + {icon && ( + + )}
)} {/* WHEN OPEN → show SEARCH BAR in place of the input */} - {open && ( - { @@ -147,7 +182,7 @@ export default function SearchSelectInput({ )} {/* DROPDOWN LIST */} - {open && ( + {open && !disabled && (
void; + disabled?: boolean; } const tagStyles = { @@ -14,19 +15,36 @@ const tagStyles = { dark: "bg-dark text-white", }; -export default function Tag({ tag, type = "info", onClick, className }: TagProps) { +const disabledTagStyles = { + info: "bg-blue/50 text-white/70", + warning: "bg-orange/50 text-white/70", + success: "bg-green/50 text-white/70", + dark: "bg-dark/50 text-white/70", +}; + +export default function Tag({ tag, type = "info", onClick, className, disabled = false }: TagProps) { + const baseStyles = disabled ? disabledTagStyles[type] : tagStyles[type]; + return (
{tag}
); diff --git a/frontend/src/graphql/generated/graphql-types.ts b/frontend/src/graphql/generated/graphql-types.ts index 11c77aa..bbb62c6 100644 --- a/frontend/src/graphql/generated/graphql-types.ts +++ b/frontend/src/graphql/generated/graphql-types.ts @@ -103,6 +103,7 @@ export type GroupMember = { isGroupAdmin: Scalars['Boolean']['output']; joined_at: Scalars['DateTimeISO']['output']; lastTempstampVu: Scalars['DateTimeISO']['output']; + user: User; userId: Scalars['Float']['output']; }; @@ -164,15 +165,18 @@ export type Mutation = { banUser: BanUserResponse; createGroup: Group; deleteGift: Scalars['Int']['output']; + deleteGroup: Scalars['String']['output']; deleteMyProfile: DeleteUserResponse; deleteUser: DeleteUserResponse; login: User; logout: Scalars['Boolean']['output']; + removeMembersFromGroup: Scalars['String']['output']; sendMessage: Message; setLastMessageVu: SetLastMessageVuOutput; signup: User; unbanUser: BanUserResponse; updateGift: Gift; + updateGroup: Group; }; @@ -212,6 +216,11 @@ export type MutationDeleteGiftArgs = { }; +export type MutationDeleteGroupArgs = { + id: Scalars['Float']['input']; +}; + + export type MutationDeleteUserArgs = { userId: Scalars['Float']['input']; }; @@ -222,6 +231,12 @@ export type MutationLoginArgs = { }; +export type MutationRemoveMembersFromGroupArgs = { + data: RemoveMembersInput; + groupId: Scalars['Float']['input']; +}; + + export type MutationSendMessageArgs = { data: NewMessageInput; }; @@ -247,6 +262,12 @@ export type MutationUpdateGiftArgs = { id: Scalars['Int']['input']; }; + +export type MutationUpdateGroupArgs = { + data: UpdateGroupInput; + id: Scalars['Float']['input']; +}; + export type MyGroupsResponse = { __typename?: 'MyGroupsResponse'; groupToken: Scalars['String']['output']; @@ -279,6 +300,7 @@ export type Query = { getAllUsers: Array; getAllUsersAdmin: Array; getAllUsersForAdmin: Array; + getGroupById: Group; getLazyMessages: GetLazyMessagesOutput; getMyProfile: User; groupWishlistItems: GroupWishlistItems; @@ -289,6 +311,11 @@ export type Query = { }; +export type QueryGetGroupByIdArgs = { + id: Scalars['Float']['input']; +}; + + export type QueryGetLazyMessagesArgs = { data: GetLazyMessagesInput; }; @@ -298,6 +325,11 @@ export type QueryGroupWishlistItemsArgs = { groupId: Scalars['Int']['input']; }; +export type RemoveMembersInput = { + userEmails?: InputMaybe>; + userIds?: InputMaybe>; +}; + export type SetLastMessageVuInput = { groupId: Scalars['Float']['input']; }; @@ -322,6 +354,15 @@ export type UpdateGiftInput = { url?: InputMaybe; }; +export type UpdateGroupInput = { + deadline: Scalars['DateTimeISO']['input']; + event_type: Scalars['String']['input']; + name: Scalars['String']['input']; + piggy_bank: Scalars['Float']['input']; + user_beneficiary?: InputMaybe; + users?: InputMaybe>; +}; + export type UpdateMyProfileInput = { date_of_birth: Scalars['String']['input']; email: Scalars['String']['input']; @@ -362,12 +403,12 @@ export type CreateGroupMutationVariables = Exact<{ }>; -export type CreateGroupMutation = { __typename?: 'Mutation', createGroup: { __typename?: 'Group', id: string, name: string, event_type: string, piggy_bank: number, deadline: any, createdAt: any, user_beneficiary?: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string } | null, user_admin: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string }, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number }> } }; +export type CreateGroupMutation = { __typename?: 'Mutation', createGroup: { __typename?: 'Group', id: string, name: string, event_type: string, piggy_bank: number, deadline: any, createdAt: any, user_beneficiary?: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string } | null, user_admin: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string }, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number, isGroupAdmin: boolean, joined_at: any, user: { __typename?: 'User', firstName: string, email: string, lastName: string } }> } }; export type GetAllMyGroupsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetAllMyGroupsQuery = { __typename?: 'Query', getAllMyGroups: { __typename?: 'MyGroupsResponse', groupToken: string, groups: Array<{ __typename?: 'Group', id: string, name: string, createdAt: any, updatedAt: any, event_type: string, piggy_bank: number, deadline: any, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number }> }> } }; +export type GetAllMyGroupsQuery = { __typename?: 'Query', getAllMyGroups: { __typename?: 'MyGroupsResponse', groupToken: string, groups: Array<{ __typename?: 'Group', id: string, name: string, createdAt: any, updatedAt: any, event_type: string, piggy_bank: number, deadline: any, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number, isGroupAdmin: boolean, joined_at: any, user: { __typename?: 'User', firstName: string, email: string, lastName: string } }>, user_admin: { __typename?: 'User', isAdmin: boolean, firstName: string, lastName: string, email: string } }> } }; export type GetAllMessageMyGroupsQueryVariables = Exact<{ [key: string]: never; }>; @@ -381,6 +422,36 @@ export type GetLazyMessagesQueryVariables = Exact<{ export type GetLazyMessagesQuery = { __typename?: 'Query', getLazyMessages: { __typename?: 'GetLazyMessagesOutput', isMaximumMessages: boolean, messages: Array<{ __typename?: 'Message', id: string, content: string, createdAt: any, updatedAt: any, isEdited: boolean, user: { __typename?: 'User', id: string, firstName: string, lastName: string, image_url?: string | null, isAdmin: boolean } }> } }; +export type GetGroupByIdQueryVariables = Exact<{ + id: Scalars['Float']['input']; +}>; + + +export type GetGroupByIdQuery = { __typename?: 'Query', getGroupById: { __typename?: 'Group', piggy_bank: number, name: string, id: string, deadline: any, event_type: string, user_admin: { __typename?: 'User', firstName: string, lastName: string, id: string, email: string, isAdmin: boolean }, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number, isGroupAdmin: boolean, joined_at: any, user: { __typename?: 'User', firstName: string, email: string, lastName: string } }>, user_beneficiary?: { __typename?: 'User', firstName: string, lastName: string, id: string } | null } }; + +export type UpdateGroupMutationVariables = Exact<{ + data: UpdateGroupInput; + updateGroupId: Scalars['Float']['input']; +}>; + + +export type UpdateGroupMutation = { __typename?: 'Mutation', updateGroup: { __typename?: 'Group', id: string, event_type: string, updatedAt: any, name: string, piggy_bank: number, deadline: any } }; + +export type DeleteGroupMutationVariables = Exact<{ + deleteGroupId: Scalars['Float']['input']; +}>; + + +export type DeleteGroupMutation = { __typename?: 'Mutation', deleteGroup: string }; + +export type RemoveMembersFromGroupMutationVariables = Exact<{ + groupId: Scalars['Float']['input']; + data: RemoveMembersInput; +}>; + + +export type RemoveMembersFromGroupMutation = { __typename?: 'Mutation', removeMembersFromGroup: string }; + export type SetLastMessageVuMutationVariables = Exact<{ data: SetLastMessageVuInput; }>; @@ -525,6 +596,13 @@ export const CreateGroupDocument = gql` id userId groupId + isGroupAdmin + joined_at + user { + firstName + email + lastName + } } } } @@ -571,6 +649,19 @@ export const GetAllMyGroupsDocument = gql` id userId groupId + isGroupAdmin + joined_at + user { + firstName + email + lastName + } + } + user_admin { + isAdmin + firstName + lastName + email } } } @@ -716,6 +807,181 @@ export type GetLazyMessagesQueryHookResult = ReturnType; export type GetLazyMessagesSuspenseQueryHookResult = ReturnType; export type GetLazyMessagesQueryResult = Apollo.QueryResult; +export const GetGroupByIdDocument = gql` + query GetGroupById($id: Float!) { + getGroupById(id: $id) { + user_admin { + firstName + lastName + id + email + } + piggy_bank + name + id + groupMember { + id + userId + groupId + isGroupAdmin + joined_at + user { + firstName + email + lastName + } + } + deadline + event_type + user_beneficiary { + firstName + lastName + id + } + user_admin { + isAdmin + firstName + lastName + email + } + } +} + `; + +/** + * __useGetGroupByIdQuery__ + * + * To run a query within a React component, call `useGetGroupByIdQuery` and pass it any options that fit your needs. + * When your component renders, `useGetGroupByIdQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetGroupByIdQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useGetGroupByIdQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetGroupByIdQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetGroupByIdDocument, options); + } +export function useGetGroupByIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetGroupByIdDocument, options); + } +export function useGetGroupByIdSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetGroupByIdDocument, options); + } +export type GetGroupByIdQueryHookResult = ReturnType; +export type GetGroupByIdLazyQueryHookResult = ReturnType; +export type GetGroupByIdSuspenseQueryHookResult = ReturnType; +export type GetGroupByIdQueryResult = Apollo.QueryResult; +export const UpdateGroupDocument = gql` + mutation UpdateGroup($data: UpdateGroupInput!, $updateGroupId: Float!) { + updateGroup(data: $data, id: $updateGroupId) { + id + event_type + updatedAt + name + piggy_bank + deadline + } +} + `; +export type UpdateGroupMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateGroupMutation__ + * + * To run a mutation, you first call `useUpdateGroupMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateGroupMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateGroupMutation, { data, loading, error }] = useUpdateGroupMutation({ + * variables: { + * data: // value for 'data' + * updateGroupId: // value for 'updateGroupId' + * }, + * }); + */ +export function useUpdateGroupMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateGroupDocument, options); + } +export type UpdateGroupMutationHookResult = ReturnType; +export type UpdateGroupMutationResult = Apollo.MutationResult; +export type UpdateGroupMutationOptions = Apollo.BaseMutationOptions; +export const DeleteGroupDocument = gql` + mutation DeleteGroup($deleteGroupId: Float!) { + deleteGroup(id: $deleteGroupId) +} + `; +export type DeleteGroupMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteGroupMutation__ + * + * To run a mutation, you first call `useDeleteGroupMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteGroupMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteGroupMutation, { data, loading, error }] = useDeleteGroupMutation({ + * variables: { + * deleteGroupId: // value for 'deleteGroupId' + * }, + * }); + */ +export function useDeleteGroupMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteGroupDocument, options); + } +export type DeleteGroupMutationHookResult = ReturnType; +export type DeleteGroupMutationResult = Apollo.MutationResult; +export type DeleteGroupMutationOptions = Apollo.BaseMutationOptions; +export const RemoveMembersFromGroupDocument = gql` + mutation RemoveMembersFromGroup($groupId: Float!, $data: RemoveMembersInput!) { + removeMembersFromGroup(groupId: $groupId, data: $data) +} + `; +export type RemoveMembersFromGroupMutationFn = Apollo.MutationFunction; + +/** + * __useRemoveMembersFromGroupMutation__ + * + * To run a mutation, you first call `useRemoveMembersFromGroupMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRemoveMembersFromGroupMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [removeMembersFromGroupMutation, { data, loading, error }] = useRemoveMembersFromGroupMutation({ + * variables: { + * groupId: // value for 'groupId' + * data: // value for 'data' + * }, + * }); + */ +export function useRemoveMembersFromGroupMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RemoveMembersFromGroupDocument, options); + } +export type RemoveMembersFromGroupMutationHookResult = ReturnType; +export type RemoveMembersFromGroupMutationResult = Apollo.MutationResult; +export type RemoveMembersFromGroupMutationOptions = Apollo.BaseMutationOptions; export const SetLastMessageVuDocument = gql` mutation SetLastMessageVu($data: SetLastMessageVuInput!) { setLastMessageVu(data: $data) { diff --git a/frontend/src/graphql/operations/groupOperations.ts b/frontend/src/graphql/operations/groupOperations.ts index cc8c43f..33dfbea 100644 --- a/frontend/src/graphql/operations/groupOperations.ts +++ b/frontend/src/graphql/operations/groupOperations.ts @@ -24,10 +24,17 @@ mutation CreateGroup($data: CreateGroupInput!) { email } groupMember { - id - userId - groupId - } + id + userId + groupId + isGroupAdmin + joined_at + user { + firstName + email + lastName + } + } } } `; @@ -44,11 +51,24 @@ export const GET_ALL_MY_GROUPS = gql` event_type piggy_bank deadline - groupMember { + groupMember { id userId groupId + isGroupAdmin + joined_at + user { + firstName + email + lastName + } } + user_admin { + isAdmin + firstName + lastName + email + } } } } @@ -99,6 +119,72 @@ export const GET_LAZY_MESSAGES = gql` } `; +export const GET_GROUP_BY_ID = gql` + query GetGroupById($id: Float!) { + getGroupById(id: $id) { + user_admin { + firstName + lastName + id + email + } + piggy_bank + name + id + groupMember { + id + userId + groupId + isGroupAdmin + joined_at + user { + firstName + email + lastName + } + } + deadline + event_type + user_beneficiary { + firstName + lastName + id + } + user_admin { + isAdmin + firstName + lastName + email + } + } + } +`; + +export const UPDATE_GROUP = gql` + mutation UpdateGroup($data: UpdateGroupInput!, $updateGroupId: Float!) { + updateGroup(data: $data, id: $updateGroupId) { + id + event_type + updatedAt + name + piggy_bank + deadline + } +} +`; + +export const DELETE_GROUP = gql` +mutation DeleteGroup($deleteGroupId: Float!) { + deleteGroup(id: $deleteGroupId) +} +`; + +export const REMOVE_GROUP_MEMBERS = gql` +mutation RemoveMembersFromGroup($groupId: Float!, $data: RemoveMembersInput!) { + removeMembersFromGroup(groupId: $groupId, data: $data) +} +`; + // pour mettre en bdd le vu du dernier message pour un groupe donné export const SET_LAST_MESSAGE_VU = gql` mutation SetLastMessageVu($data: SetLastMessageVuInput!) { diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts index b334338..080363e 100644 --- a/frontend/src/hooks/useChat.ts +++ b/frontend/src/hooks/useChat.ts @@ -30,3 +30,10 @@ export function useLiveChat(setMessages: (response: { newMessage: message; group return { connectToRoom, sendMessage }; } + +// const sendMessage = useCallback(...) +// const connectToRoom = useCallback(...) +// return useMemo(() => ({ +// sendMessage, +// connectToRoom, +// }), [sendMessage, connectToRoom]); diff --git a/frontend/src/hooks/useUserPermissions.ts b/frontend/src/hooks/useUserPermissions.ts new file mode 100644 index 0000000..2da9514 --- /dev/null +++ b/frontend/src/hooks/useUserPermissions.ts @@ -0,0 +1,19 @@ +import type { Group } from "../graphql/generated/graphql-types"; +import type { UserProfile } from "../zustand/myProfileStore"; +import { useMyProfileStore } from "../zustand/myProfileStore"; + +type UseUserPermissionsResult = { + currentUser: UserProfile | null; + isAdmin: boolean; +}; + +export function useUserPermissions(group?: Group): UseUserPermissionsResult { + const currentUser = useMyProfileStore((s) => s.userProfile); + + const isAdmin = !!group && !!currentUser && group.user_admin?.id === currentUser.id; + + return { + currentUser, + isAdmin, + }; +} diff --git a/frontend/src/pages/Conversations.tsx b/frontend/src/pages/Conversations.tsx index 1da7fdb..d3775b8 100644 --- a/frontend/src/pages/Conversations.tsx +++ b/frontend/src/pages/Conversations.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { HiDotsVertical } from "react-icons/hi"; import { LuCirclePlus, LuMessageCircleMore } from "react-icons/lu"; -import CreateGroupForm from "../components/forms/CreateGroupForm"; +import GroupFormindex from "../components/forms/groups/index"; import AddFundsModal from "../components/groups/AddFundsModal"; import Groups from "../components/groups/Groups"; import Messaging from "../components/groups/Messaging/Messaging"; @@ -50,12 +50,16 @@ export default function Conversations() { // const [nbNewMessagesRef, setNbNewMessagesRef] = useState<{ [groupId: number]: number }>({}); const { updateLastVu, getNbNewMessages, getLastVu } = useVuMessage(); const [indexGroups, setIndexGroup] = useState(0); + const [selectedGroupId, setSelectedGroupId] = useState(null); const contenairMessageRef = useRef(null); const handlerNewMessage = (response: { newMessage: Message; groupId: number }) => { setMessages((prev) => { const clone = structuredClone(prev); + if (!clone[response.groupId]) { + clone[response.groupId] = []; + } clone[response.groupId]?.unshift(response.newMessage); return clone; }); @@ -88,7 +92,9 @@ export default function Conversations() { const chat = useLiveChat(handlerNewMessage); function setActiveGroup(group: GetAllMyGroupsQuery["getAllMyGroups"]["groups"][number]) { - setIndexGroup(groups.findIndex((g) => Number(g.id) === Number(group.id))); + const groupIndex = groups.findIndex((g) => Number(g.id) === Number(group.id)); + setIndexGroup(groupIndex); + setSelectedGroupId(Number(group.id)); } // Handle mobile view changes - hide/show bottom navigation @@ -118,10 +124,24 @@ export default function Conversations() { if (groupData?.getAllMyGroups.groups.length === 0) { setIndexGroup(-1); + setSelectedGroupId(null); return; } - setGroups(groupData?.getAllMyGroups.groups || []); + const newGroups = groupData?.getAllMyGroups.groups || []; + setGroups(newGroups); + + // Initialize messages map with empty arrays for all groups + setMessages((prev) => { + const updated = { ...prev }; + newGroups.forEach((group) => { + const groupId = Number(group.id); + if (!updated[groupId]) { + updated[groupId] = []; + } + }); + return updated; + }); const existing = indexGroups !== -1 @@ -147,14 +167,6 @@ export default function Conversations() { }); }, [messageData]); - useEffect(() => { - if (indexGroups === -1) { - setIndexGroup(-1); - return; - } - setIndexGroup(indexGroups); - }, [indexGroups]); - const addMessage = (groupId: number, message: Message[]) => { setMessages((prev) => { const clone = structuredClone(prev); @@ -292,9 +304,10 @@ export default function Conversations() { {/* Create Group Modal */} {isCreateGroupModalOpen && ( setIsCreateGroupModalOpen(false)} isOpen={isCreateGroupModalOpen}> - setIsCreateGroupModalOpen(false)} onCancel={() => setIsCreateGroupModalOpen(false)} + groupId={selectedGroupId || undefined} /> )} @@ -485,10 +498,11 @@ export default function Conversations() {
{indexGroups !== -1 && groups.length > 0 && + indexGroups < groups.length && messages[Number(groups[indexGroups].id)] !== undefined && ( { return ( diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 0212013..c1740ab 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -1,6 +1,6 @@ -import RegisterForm from "../components/auth/RegisterForm"; +import RegisterForm from "../components/forms/auth/RegisterForm"; import InfoHome from "../components/InfoHome"; -import "../components/auth/auth.css"; +import "../components/forms/auth/auth.css"; const RegisterPage = () => { return ( diff --git a/frontend/src/zustand/myProfileStore.ts b/frontend/src/zustand/myProfileStore.ts index f6f8ca2..0b2663a 100644 --- a/frontend/src/zustand/myProfileStore.ts +++ b/frontend/src/zustand/myProfileStore.ts @@ -3,22 +3,22 @@ import { defaultPictureProfile } from "../data/pictureDefault"; import type { GetMyProfileQuery } from "../graphql/generated/graphql-types"; type State = { - userProfile: null | UserProfil; - setUserProfile: (user: null | UserProfil) => void; + userProfile: null | UserProfile; + setUserProfile: (user: null | UserProfile) => void; clearUserProfile: () => void; }; -type UserProfil = GetMyProfileQuery["getMyProfile"]; +export type UserProfile = GetMyProfileQuery["getMyProfile"]; export const useMyProfileStore = create((set) => ({ userProfile: null, - setUserProfile: (newUser: UserProfil | null) => + setUserProfile: (newUser: UserProfile | null) => set(() => ({ userProfile: newUser ? ({ ...newUser, image_url: newUser.image_url ? `/service/picture/${newUser.image_url}` : defaultPictureProfile, - } as UserProfil) + } as UserProfile) : null, })), clearUserProfile: () => set({ userProfile: null }),