diff --git a/src/Interfaces/IContactMessages.ts b/src/Interfaces/IContactMessages.ts new file mode 100644 index 000000000..0a1d1c04b --- /dev/null +++ b/src/Interfaces/IContactMessages.ts @@ -0,0 +1,15 @@ +import { GoalItem } from "@src/models/GoalItem"; + +export interface SharedGoalMessage { + relId: string; + goalWithChildrens: GoalItem[]; + lastProcessedTimestamp: string; + type: "shareMessage"; + installId: string; + TTL: number; +} + +export interface SharedGoalMessageResponse { + success: boolean; + response: SharedGoalMessage[]; +} diff --git a/src/api/GoalsAPI/index.ts b/src/api/GoalsAPI/index.ts index b0cd37cae..73241e145 100644 --- a/src/api/GoalsAPI/index.ts +++ b/src/api/GoalsAPI/index.ts @@ -157,22 +157,22 @@ export const unarchiveUserGoal = async (goal: GoalItem) => { await unarchiveGoal(goal); }; -export const removeGoal = async (goal: GoalItem) => { +export const removeGoal = async (goal: GoalItem, permanently = false) => { await deleteHintItem(goal.id); await Promise.allSettled([ db.goalsCollection.delete(goal.id).catch((err) => console.log("failed to delete", err)), - addDeletedGoal(goal), + permanently ? null : addDeletedGoal(goal), ]); }; -export const removeChildrenGoals = async (parentGoalId: string) => { +export const removeChildrenGoals = async (parentGoalId: string, permanently = false) => { const childrenGoals = await getChildrenGoals(parentGoalId); if (childrenGoals.length === 0) { return; } childrenGoals.forEach((goal) => { - removeChildrenGoals(goal.id); - removeGoal(goal); + removeChildrenGoals(goal.id, permanently); + removeGoal(goal, permanently); }); }; @@ -312,9 +312,9 @@ export const notifyNewColabRequest = async (id: string, relId: string) => { // }); // }; -export const removeGoalWithChildrens = async (goal: GoalItem) => { - await removeChildrenGoals(goal.id); - await removeGoal(goal); +export const removeGoalWithChildrens = async (goal: GoalItem, permanently = false) => { + await removeChildrenGoals(goal.id, permanently); + await removeGoal(goal, permanently); if (goal.parentGoalId !== "root") { getGoal(goal.parentGoalId).then(async (parentGoal: GoalItem) => { const parentGoalSublist = parentGoal.sublist; diff --git a/src/api/SharedWMAPI/index.ts b/src/api/SharedWMAPI/index.ts index 00818799c..c1d942b73 100644 --- a/src/api/SharedWMAPI/index.ts +++ b/src/api/SharedWMAPI/index.ts @@ -2,7 +2,8 @@ import { db } from "@models"; import { GoalItem } from "@src/models/GoalItem"; import { createGoalObjectFromTags } from "@src/helpers/GoalProcessor"; -import { addDeletedGoal, addGoal } from "../GoalsAPI"; +import { addGoal } from "../GoalsAPI"; +import { getContactByRelId } from "../ContactsAPI"; export const addSharedWMSublist = async (parentGoalId: string, goalIds: string[]) => { db.transaction("rw", db.sharedWMCollection, async () => { @@ -17,29 +18,53 @@ export const addSharedWMSublist = async (parentGoalId: string, goalIds: string[] }); }; -export const addSharedWMGoal = async (goalDetails: object) => { +export const addSharedWMGoal = async (goalDetails: GoalItem, relId = "") => { + console.log("[addSharedWMGoal] Input goal details:", goalDetails); + console.log("[addSharedWMGoal] Input relId:", relId); + const { participants } = goalDetails; - const newGoal = createGoalObjectFromTags({ ...goalDetails, typeOfGoal: "shared" }); - if (participants) newGoal.participants = participants; + let updatedParticipants = participants || []; + + if (relId) { + const contact = await getContactByRelId(relId); + if (contact) { + const contactExists = updatedParticipants.some((p) => p.relId === relId); + if (!contactExists) { + updatedParticipants = [...updatedParticipants, { ...contact, following: true, type: "sharer" }]; + } + } + } + + console.log("[addSharedWMGoal] Updated participants:", updatedParticipants); + const newGoal = createGoalObjectFromTags({ + ...goalDetails, + typeOfGoal: "shared", + participants: updatedParticipants, + }); + await db .transaction("rw", db.sharedWMCollection, async () => { await db.sharedWMCollection.add(newGoal); + console.log("[addSharedWMGoal] Goal added to sharedWMCollection"); }) .then(async () => { const { parentGoalId } = newGoal; if (parentGoalId !== "root") { + console.log("[addSharedWMGoal] Adding goal to parent sublist. ParentId:", parentGoalId); await addSharedWMSublist(parentGoalId, [newGoal.id]); } }) .catch((e) => { - console.log(e.stack || e); + console.error("[addSharedWMGoal] Error:", e.stack || e); }); + + console.log("[addSharedWMGoal] Successfully created goal with ID:", newGoal.id); return newGoal.id; }; -export const addGoalsInSharedWM = async (goals: GoalItem[]) => { +export const addGoalsInSharedWM = async (goals: GoalItem[], relId: string) => { goals.forEach((ele) => { - addSharedWMGoal(ele).then((res) => console.log(res, "added")); + addSharedWMGoal(ele, relId).then((res) => console.log(res, "added")); }); }; diff --git a/src/common/Icon.tsx b/src/common/Icon.tsx index 86c0ade97..25b45e7a4 100644 --- a/src/common/Icon.tsx +++ b/src/common/Icon.tsx @@ -656,6 +656,23 @@ const Icon: React.FC = ({ title, active }) => { ); + case "Move": + return ( + + + + + + ); + default: return
; } diff --git a/src/common/ZButton.tsx b/src/common/ZButton.tsx new file mode 100644 index 000000000..2a9663155 --- /dev/null +++ b/src/common/ZButton.tsx @@ -0,0 +1,24 @@ +import { darkModeState } from "@src/store"; +import React from "react"; +import { useRecoilValue } from "recoil"; + +interface ZButtonProps { + children: React.ReactNode; + onClick?: () => void; + className?: string; +} + +const ZButton: React.FC = ({ children, onClick, className }) => { + const darkModeStatus = useRecoilValue(darkModeState); + + const defaultClassName = `default-btn${darkModeStatus ? "-dark" : ""}`; + const combinedClassName = className ? `${defaultClassName} ${className}` : defaultClassName; + + return ( + + ); +}; + +export default ZButton; diff --git a/src/components/GoalsComponents/DisplayChangesModal/AcceptBtn.tsx b/src/components/GoalsComponents/DisplayChangesModal/AcceptBtn.tsx index 0b2f87fa5..5e5fe00e4 100644 --- a/src/components/GoalsComponents/DisplayChangesModal/AcceptBtn.tsx +++ b/src/components/GoalsComponents/DisplayChangesModal/AcceptBtn.tsx @@ -34,7 +34,9 @@ const AcceptBtn = ({ typeAtPriority, acceptChanges }: AcceptBtnProps) => { {typeAtPriority === "restored" && "Restore for me too"} {typeAtPriority === "deleted" && "Delete for me too"} {typeAtPriority === "subgoals" && "Add all checked"} + {typeAtPriority === "newGoalMoved" && "Move for me too"} {typeAtPriority === "modifiedGoals" && "Make all checked changes"} + {typeAtPriority === "moved" && "Move for me too"} ); }; diff --git a/src/components/GoalsComponents/DisplayChangesModal/DisplayChangesModal.tsx b/src/components/GoalsComponents/DisplayChangesModal/DisplayChangesModal.tsx index 24df73de1..c214fdf7c 100644 --- a/src/components/GoalsComponents/DisplayChangesModal/DisplayChangesModal.tsx +++ b/src/components/GoalsComponents/DisplayChangesModal/DisplayChangesModal.tsx @@ -19,10 +19,12 @@ import SubHeader from "@src/common/SubHeader"; import ContactItem from "@src/models/ContactItem"; import ZModal from "@src/common/ZModal"; +import { addGoalToNewParentSublist, getAllDescendants, removeGoalFromParentSublist } from "@src/helpers/GoalController"; import Header from "./Header"; import AcceptBtn from "./AcceptBtn"; import IgnoreBtn from "./IgnoreBtn"; import "./DisplayChangesModal.scss"; +import { getMovedSubgoalsList } from "./ShowChanges"; const DisplayChangesModal = ({ currentMainGoal }: { currentMainGoal: GoalItem }) => { const darkModeStatus = useRecoilValue(darkModeState); @@ -34,6 +36,33 @@ const DisplayChangesModal = ({ currentMainGoal }: { currentMainGoal: GoalItem }) const [goalUnderReview, setGoalUnderReview] = useState(); const [participants, setParticipants] = useState([]); const [currentDisplay, setCurrentDisplay] = useState("none"); + const [oldParentTitle, setOldParentTitle] = useState(""); + const [newParentTitle, setNewParentTitle] = useState(""); + + useEffect(() => { + const fetchParentTitles = async () => { + if (!goalUnderReview) return; + + try { + const currentGoalInDB = await getGoal(goalUnderReview.id); + const oldParentId = currentGoalInDB?.parentGoalId; + + const [oldParent, newParent] = await Promise.all([ + oldParentId ? getGoal(oldParentId) : null, + getGoal(goalUnderReview.parentGoalId), + ]); + + setOldParentTitle(oldParent?.title || "root"); + setNewParentTitle(newParent?.title || "Non-shared goal"); + } catch (error) { + console.error("Error fetching parent titles:", error); + setOldParentTitle("root"); + setNewParentTitle("Non-shared goal"); + } + }; + + fetchParentTitles(); + }, [goalUnderReview]); const [showSuggestions, setShowSuggestions] = useState(false); const [unselectedChanges, setUnselectedChanges] = useState([]); @@ -125,13 +154,41 @@ const DisplayChangesModal = ({ currentMainGoal }: { currentMainGoal: GoalItem }) if (!goalUnderReview || !currentMainGoal) { return; } - const removeChanges = currentDisplay === "subgoals" ? newGoals.map(({ goal }) => goal.id) : [goalUnderReview.id]; + const removeChanges = + currentDisplay === "subgoals" || currentDisplay === "newGoalMoved" + ? newGoals.map(({ goal }) => goal.id) + : currentDisplay === "moved" + ? [goalUnderReview.id, ...(await getAllDescendants(goalUnderReview.id)).map((goal: GoalItem) => goal.id)] + : [goalUnderReview.id]; + if (currentDisplay !== "none") { await deleteGoalChangesInID(currentMainGoal.id, participants[activePPT].relId, currentDisplay, removeChanges); } setCurrentDisplay("none"); }; + const handleMoveChanges = async () => { + if (!goalUnderReview) { + console.log("No goal under review."); + return; + } + const parentGoal = await getGoal(goalUnderReview.parentGoalId); + + await Promise.all([ + updateGoal(goalUnderReview.id, { parentGoalId: parentGoal?.id ?? "root" }), + removeGoalFromParentSublist(goalUnderReview.id, parentGoal?.title ?? "root"), + addGoalToNewParentSublist(goalUnderReview.id, parentGoal?.id ?? "root"), + ]); + + // TODO: handle this later + // await sendUpdatedGoal( + // goalUnderReview.id, + // [], + // true, + // updatesIntent === "suggestion" ? [] : [participants[activePPT].relId], + // ); + }; + const acceptChanges = async () => { if (!goalUnderReview) { return; @@ -139,7 +196,10 @@ const DisplayChangesModal = ({ currentMainGoal }: { currentMainGoal: GoalItem }) if (currentDisplay !== "none") { await deleteChanges(); } - if (currentDisplay === "subgoals") { + if (currentDisplay === "moved") { + await handleMoveChanges(); + } + if (currentDisplay === "subgoals" || currentDisplay === "newGoalMoved") { const goalsToBeSelected = newGoals .filter(({ goal }) => !unselectedChanges.includes(goal.id)) .map(({ goal }) => goal); @@ -205,12 +265,16 @@ const DisplayChangesModal = ({ currentMainGoal }: { currentMainGoal: GoalItem }) console.log("🚀 ~ getChanges ~ changedGoal:", changedGoal); if (changedGoal) { setGoalUnderReview({ ...changedGoal }); - if (typeAtPriority === "subgoals") { + // TODO: remove the newGoalsMoved and try handle in subgoal only + if (typeAtPriority === "subgoals" || typeAtPriority === "newGoalMoved") { setNewGoals(goals || []); } else if (typeAtPriority === "modifiedGoals") { setUpdatesIntent(goals[0].intent); const incGoal: GoalItem = { ...goals[0].goal }; setUpdateList({ ...findGoalTagChanges(changedGoal, incGoal) }); + } else if (typeAtPriority === "moved") { + setUpdatesIntent(goals[0].intent); + setGoalUnderReview({ ...goals[0].goal }); } } } @@ -237,6 +301,23 @@ const DisplayChangesModal = ({ currentMainGoal }: { currentMainGoal: GoalItem }) init(); }, [currentMainGoal]); + const getChangedGoalFromRoot = async (rootGoal: GoalItem, relId: string) => { + const { typeAtPriority, goals, parentId } = await jumpToLowestChanges(rootGoal.id, relId); + + if (typeAtPriority === "none") return { typeAtPriority, goals, parentId }; + + const changedGoal = await getGoal(parentId); + if (!changedGoal) return { typeAtPriority, goals, parentId }; + + return { + typeAtPriority, + goals, + parentId, + changedGoal, + rootGoal, + }; + }; + return ( {currentMainGoal && ( @@ -289,7 +370,8 @@ const DisplayChangesModal = ({ currentMainGoal }: { currentMainGoal: GoalItem }) )} {["deleted", "archived", "restored"].includes(currentDisplay) &&
} {currentDisplay === "modifiedGoals" && getEditChangesList()} - {currentDisplay === "subgoals" && getSubgoalsList()} + {(currentDisplay === "subgoals" || currentDisplay === "newGoalMoved") && getSubgoalsList()} + {currentDisplay === "moved" && getMovedSubgoalsList(goalUnderReview, oldParentTitle, newParentTitle)}
{goalUnderReview && ( diff --git a/src/components/GoalsComponents/DisplayChangesModal/Header.tsx b/src/components/GoalsComponents/DisplayChangesModal/Header.tsx index 16f5cc644..6133809cb 100644 --- a/src/components/GoalsComponents/DisplayChangesModal/Header.tsx +++ b/src/components/GoalsComponents/DisplayChangesModal/Header.tsx @@ -18,6 +18,12 @@ const Header = ({ {contactName} added to {title}.    Add as well ? ); + case "newGoalMoved": + return ( + <> + {contactName} moved {title} to {title}.    Move as well ? + + ); case "modifiedGoals": return ( <> @@ -42,6 +48,12 @@ const Header = ({ {contactName} restored {title}. ); + case "moved": + return ( + <> + {contactName} moved {title}. + + ); default: return <> ; } diff --git a/src/components/GoalsComponents/DisplayChangesModal/ShowChanges.scss b/src/components/GoalsComponents/DisplayChangesModal/ShowChanges.scss new file mode 100644 index 000000000..fabe9ec86 --- /dev/null +++ b/src/components/GoalsComponents/DisplayChangesModal/ShowChanges.scss @@ -0,0 +1,79 @@ +.move-info-container { + display: flex; + flex-direction: column; + gap: 20px; + background: var(--secondary-background); + border-radius: 12px; + padding: 24px; + border: 1px solid var(--default-border-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + + .move-info-item { + display: flex; + flex-direction: column; + gap: 8px; + + .move-info-label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .move-info-value { + font-size: 14px; + color: var(--text-primary); + padding: 12px 16px; + background: var(--primary-background); + border-radius: 8px; + border: 1px solid var(--default-border-color); + line-height: 1.4; + + &.highlight-box { + background: var(--selection-color); + border: none; + font-weight: 500; + } + } + } + + .move-direction-container { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + padding: 16px; + background: var(--primary-background); + border-radius: 12px; + border: 1px solid var(--default-border-color); + + .arrow { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--secondary-background); + border-radius: 50%; + color: var(--text-secondary); + font-size: 18px; + margin-top: 24px; + } + } + + .warning-message { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--bottom-nav-color); + border-radius: 8px; + margin-top: 4px; + font-size: 13px; + + .anticon { + font-size: 16px; + } + } +} diff --git a/src/components/GoalsComponents/DisplayChangesModal/ShowChanges.tsx b/src/components/GoalsComponents/DisplayChangesModal/ShowChanges.tsx new file mode 100644 index 000000000..2277bcf1a --- /dev/null +++ b/src/components/GoalsComponents/DisplayChangesModal/ShowChanges.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { GoalItem } from "@src/models/GoalItem"; +import "./ShowChanges.scss"; +import { InfoCircleOutlined } from "@ant-design/icons"; + +export const getMovedSubgoalsList = ( + goalUnderReview: GoalItem | undefined, + oldParentTitle: string, + newParentTitle: string, +) => { + if (!goalUnderReview) return null; + + return ( +
+
+ Goal Being Moved +
{goalUnderReview.title}
+
+ +
+
+ From +
{oldParentTitle}
+
+ +
→
+ +
+ To +
{newParentTitle}
+
+
+ + {newParentTitle === "Non-shared goal" && ( +
+ + The new parent goal is not shared. The goal will be moved to the root. +
+ )} +
+ ); +}; diff --git a/src/components/GoalsComponents/MoveGoalButton.tsx b/src/components/GoalsComponents/MoveGoalButton.tsx new file mode 100644 index 000000000..2859c08d4 --- /dev/null +++ b/src/components/GoalsComponents/MoveGoalButton.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { moveGoalHierarchy } from "@src/helpers/GoalController"; +import ZButton from "@src/common/ZButton"; +import { GoalItem } from "@src/models/GoalItem"; +import { lastAction } from "@src/store"; +import { moveGoalState } from "@src/store/moveGoalState"; +import useNavigateToSubgoal from "@src/store/useNavigateToSubgoal"; + +interface GoalMoveButtonProps { + targetGoal: GoalItem; +} + +const GoalMoveButton: React.FC = ({ targetGoal }) => { + const navigateToSubgoal = useNavigateToSubgoal(); + const [selectedGoal, setSelectedGoal] = useRecoilState(moveGoalState); + const setLastAction = useSetRecoilState(lastAction); + + const handleClick = () => { + if (selectedGoal && targetGoal?.id) { + moveGoalHierarchy(selectedGoal.id, targetGoal.id) + .then(() => { + setLastAction("goalMoved"); + }) + .then(() => { + navigateToSubgoal(targetGoal); + }) + .finally(() => { + setSelectedGoal(null); + }); + } + }; + + return ( + + Move Here + + ); +}; + +export default GoalMoveButton; diff --git a/src/components/GoalsComponents/MyGoal/MoveGoalGuide.tsx b/src/components/GoalsComponents/MyGoal/MoveGoalGuide.tsx new file mode 100644 index 000000000..299527f5d --- /dev/null +++ b/src/components/GoalsComponents/MyGoal/MoveGoalGuide.tsx @@ -0,0 +1,33 @@ +import React, { MutableRefObject } from "react"; +import { Tour } from "antd"; +import type { TourProps } from "antd"; +import { useRecoilState } from "recoil"; +import { moveGoalState } from "@src/store/moveGoalState"; + +interface MoveGoalGuideProps { + goalComponentRef: MutableRefObject; +} + +const MoveGoalGuide: React.FC = ({ goalComponentRef }) => { + const [goalToMove, setGoalToMove] = useRecoilState(moveGoalState); + + const steps: TourProps["steps"] = [ + { + title: "Navigate to the goal you want to move into.", + target: () => goalComponentRef.current as HTMLElement, + nextButtonProps: { + children: "Close", + onClick: () => setGoalToMove(null), + }, + placement: "bottom", + className: "move-goal-guide", + }, + ]; + return ( +
+ setGoalToMove(null)} steps={steps} /> +
+ ); +}; + +export default MoveGoalGuide; diff --git a/src/components/GoalsComponents/MyGoal/MyGoal.tsx b/src/components/GoalsComponents/MyGoal/MyGoal.tsx index f4add9bfd..183e47c41 100644 --- a/src/components/GoalsComponents/MyGoal/MyGoal.tsx +++ b/src/components/GoalsComponents/MyGoal/MyGoal.tsx @@ -8,9 +8,11 @@ import { ILocationState, ImpossibleGoal } from "@src/Interfaces"; import { useParentGoalContext } from "@src/contexts/parentGoal-context"; import { extractLinks, isGoalCode } from "@src/utils/patterns"; import useGoalActions from "@src/hooks/useGoalActions"; +import { moveGoalState } from "@src/store/moveGoalState"; import GoalAvatar from "../GoalAvatar"; import GoalTitle from "./components/GoalTitle"; import GoalDropdown from "./components/GoalDropdown"; +import GoalMoveButton from "../MoveGoalButton"; interface MyGoalProps { goal: ImpossibleGoal; @@ -22,6 +24,10 @@ const MyGoal: React.FC = ({ goal, dragAttributes, dragListeners }) const { partnerId } = useParams(); const isPartnerModeActive = !!partnerId; + const goalToMove = useRecoilValue(moveGoalState); + + const shouldRenderMoveButton = goalToMove && goal.id !== goalToMove.id && goal.id !== goalToMove.parentGoalId; + const [expandGoalId, setExpandGoalId] = useState("root"); const [isAnimating, setIsAnimating] = useState(true); const { copyCode } = useGoalActions(); @@ -91,7 +97,7 @@ const MyGoal: React.FC = ({ goal, dragAttributes, dragListeners }) key={String(`goal-${goal.id}`)} className={`user-goal${darkModeStatus ? "-dark" : ""} ${ expandGoalId === goal.id && isAnimating ? "goal-glow" : "" - }`} + } ${goalToMove && goalToMove.id === goal.id ? "goal-to-move-selected" : ""}`} >
= ({ goal, dragAttributes, dragListeners })
handleGoalClick(e)}>
+ {shouldRenderMoveButton && }
- {!isPartnerModeActive && goal.participants?.length > 0 && } + {!shouldRenderMoveButton && !isPartnerModeActive && goal.participants?.length > 0 && }
); }; diff --git a/src/components/GoalsComponents/MyGoal/index.scss b/src/components/GoalsComponents/MyGoal/index.scss new file mode 100644 index 000000000..074d74f66 --- /dev/null +++ b/src/components/GoalsComponents/MyGoal/index.scss @@ -0,0 +1,20 @@ +.move-goal-guide { + width: 250px; + .ant-tour-title { + font-size: 14px; + } +} + +.user-goal-main { + .move-goal-button { + margin-top: 0; + margin-right: 10px; + align-self: center; + min-width: fit-content; + } +} + +.goal-to-move-selected { + opacity: 0.5; + pointer-events: none; +} diff --git a/src/components/GoalsComponents/MyGoalActions/RegularGoalActions.tsx b/src/components/GoalsComponents/MyGoalActions/RegularGoalActions.tsx index 39c6d6774..06b9d8245 100644 --- a/src/components/GoalsComponents/MyGoalActions/RegularGoalActions.tsx +++ b/src/components/GoalsComponents/MyGoalActions/RegularGoalActions.tsx @@ -28,7 +28,7 @@ const RegularGoalActions = ({ goal }: { goal: GoalItem }) => { const navigate = useNavigate(); const { t } = useTranslation(); const { partnerId } = useParams(); - const { openEditMode } = useGoalStore(); + const { openEditMode, handleMove } = useGoalStore(); const { state, pathname }: { state: ILocationState; pathname: string } = useLocation(); const { deleteGoalAction } = useGoalActions(); const isPartnerModeActive = !!partnerId; @@ -159,6 +159,16 @@ const RegularGoalActions = ({ goal }: { goal: GoalItem }) => { >
+ {!isPartnerModeActive && ( +
{ + handleMove(goal); + }} + > + +
+ )}
); diff --git a/src/helpers/GoalController.ts b/src/helpers/GoalController.ts index eca44850a..7614c0e44 100644 --- a/src/helpers/GoalController.ts +++ b/src/helpers/GoalController.ts @@ -1,5 +1,6 @@ -import { GoalItem } from "@src/models/GoalItem"; -import { getSelectedLanguage, inheritParentProps } from "@src/utils"; +/* eslint-disable no-await-in-loop */ +import { GoalItem, IParticipant } from "@src/models/GoalItem"; +import { inheritParentProps } from "@src/utils"; import { sendUpdatesToSubscriber } from "@src/services/contact.service"; import { getSharedWMGoal, removeSharedWMChildrenGoals, updateSharedWMGoal } from "@src/api/SharedWMAPI"; import { @@ -10,6 +11,7 @@ import { removeGoalWithChildrens, getParticipantsOfGoals, getHintsFromAPI, + getChildrenGoals, } from "@src/api/GoalsAPI"; import { addHintItem, updateHintItem } from "@src/api/HintsAPI"; import { restoreUserGoal } from "@src/api/TrashAPI"; @@ -17,33 +19,146 @@ import { sendFinalUpdateOnGoal, sendUpdatedGoal } from "./PubSubController"; export const createGoal = async (newGoal: GoalItem, parentGoalId: string, ancestors: string[], hintOption: boolean) => { const level = ancestors.length; + if (hintOption) { getHintsFromAPI(newGoal) - .then((hints) => addHintItem(newGoal.id, hintOption, hints || [])) + .then((hints) => { + addHintItem(newGoal.id, hintOption, hints || []); + }) .catch((error) => console.error("Error fetching hints:", error)); } if (parentGoalId && parentGoalId !== "root") { const parentGoal = await getGoal(parentGoalId); - if (!parentGoal) return { parentGoal: null }; - const newGoalId = await addGoal(inheritParentProps(newGoal, parentGoal)); + if (!parentGoal) { + console.warn("Parent goal not found:", parentGoalId); + return { parentGoal: null }; + } + + const ancestorGoals = await Promise.all(ancestors.map((id) => getGoal(id))); + const allParticipants = new Map(); + + [...ancestorGoals, parentGoal].forEach((goal) => { + if (!goal?.participants) return; + goal.participants.forEach((participant) => { + if (participant.following) { + allParticipants.set(participant.relId, participant); + } + }); + }); + const goalWithParentProps = inheritParentProps(newGoal, parentGoal); + const updatedGoal = { + ...goalWithParentProps, + participants: Array.from(allParticipants.values()), + }; + + const newGoalId = await addGoal(updatedGoal); + + if (newGoalId) { + const subscribers = await getParticipantsOfGoals(ancestors); + await Promise.all( + subscribers.map(async ({ sub, rootGoalId }) => { + await sendUpdatesToSubscriber(sub, rootGoalId, "subgoals", [ + { + level, + goal: { + ...updatedGoal, + id: newGoalId, + rootGoalId, + participants: [], + }, + }, + ]); + }), + ); + } + + const newSublist = parentGoal && parentGoal.sublist ? [...parentGoal.sublist, newGoalId] : [newGoalId]; + await updateGoal(parentGoalId, { sublist: newSublist }); + return { parentGoal }; + } + + await addGoal(newGoal); + return { parentGoal: null }; +}; + +export const getGoalAncestors = async (goalId: string): Promise => { + const ancestors: string[] = []; + let currentGoalId = goalId; + + while (currentGoalId !== "root") { + const currentGoal = await getGoal(currentGoalId); + if (!currentGoal || currentGoal.parentGoalId === "root") break; + ancestors.unshift(currentGoal.parentGoalId); + currentGoalId = currentGoal.parentGoalId; + } + + return ancestors; +}; + +export const getAllDescendants = async (goalId: string): Promise => { + const descendants: GoalItem[] = []; + + const processGoalAndChildren = async (currentGoalId: string) => { + const childrenGoals = await getChildrenGoals(currentGoalId); + await Promise.all( + childrenGoals.map(async (childGoal) => { + descendants.push(childGoal); + await processGoalAndChildren(childGoal.id); + }), + ); + }; + + await processGoalAndChildren(goalId); + return descendants; +}; + +export const createSharedGoal = async (newGoal: GoalItem, parentGoalId: string, ancestors: string[]) => { + const level = ancestors.length; + + if (parentGoalId && parentGoalId !== "root") { + const parentGoal = await getGoal(parentGoalId); + if (!parentGoal) { + console.log("Parent goal not found"); + return { parentGoal: null }; + } + + const newGoalId = newGoal.id; const subscribers = await getParticipantsOfGoals(ancestors); + if (newGoalId) { subscribers.map(async ({ sub, rootGoalId }) => { - sendUpdatesToSubscriber(sub, rootGoalId, "subgoals", [ + await sendUpdatesToSubscriber(sub, rootGoalId, "newGoalMoved", [ { level, - goal: { ...newGoal, id: newGoalId }, + goal: { ...newGoal, id: newGoalId, parentGoalId }, }, - ]).then(() => console.log("update sent")); + ]); }); + + const descendants = await getAllDescendants(newGoalId); + if (descendants.length > 0) { + subscribers.map(async ({ sub, rootGoalId }) => { + await sendUpdatesToSubscriber( + sub, + rootGoalId, + "newGoalMoved", + descendants.map((descendant) => ({ + level: level + 1, + goal: { + ...descendant, + rootGoalId, + }, + })), + ); + }); + } + + console.log("Updates sent successfully"); } - const newSublist = parentGoal && parentGoal.sublist ? [...parentGoal.sublist, newGoalId] : [newGoalId]; - await updateGoal(parentGoalId, { sublist: newSublist }); return { parentGoal }; } - await addGoal(newGoal); return { parentGoal: null }; }; @@ -100,3 +215,162 @@ export const deleteSharedGoal = async (goal: GoalItem) => { }); } }; + +const updateRootGoal = async (goalId: string, newRootGoalId: string) => { + await updateGoal(goalId, { rootGoalId: newRootGoalId }); + + const childrenGoals = await getChildrenGoals(goalId); + if (childrenGoals) { + childrenGoals.forEach(async (goal: GoalItem) => { + await updateRootGoal(goal.id, newRootGoalId); + }); + } +}; + +export const getRootGoalId = async (goalId: string): Promise => { + const goal = await getGoal(goalId); + if (!goal || goal.parentGoalId === "root") { + return goal?.id || "root"; + } + return getRootGoalId(goal.parentGoalId); +}; + +export const updateRootGoalNotification = async (goalId: string) => { + const rootGoalId = await getRootGoalId(goalId); + if (rootGoalId !== "root") { + await updateGoal(rootGoalId, { newUpdates: true }); + } +}; + +export const removeGoalFromParentSublist = async (goalId: string, parentGoalId: string) => { + const parentGoal = await getGoal(parentGoalId); + if (!parentGoal) return; + + const parentGoalSublist = parentGoal.sublist; + const childGoalIndex = parentGoalSublist.indexOf(goalId); + if (childGoalIndex !== -1) { + parentGoalSublist.splice(childGoalIndex, 1); + } + await updateGoal(parentGoal.id, { sublist: parentGoalSublist }); +}; + +export const addGoalToNewParentSublist = async (goalId: string, newParentGoalId: string) => { + const newParentGoal = await getGoal(newParentGoalId); + if (!newParentGoal) return; + + const newParentGoalSublist = newParentGoal.sublist; + newParentGoalSublist.push(goalId); + await updateGoal(newParentGoal.id, { sublist: newParentGoalSublist }); +}; + +export const getGoalHistoryToRoot = async (goalId: string): Promise<{ goalID: string; title: string }[]> => { + const history: { goalID: string; title: string }[] = []; + let currentGoalId = goalId; + + while (currentGoalId !== "root") { + const currentGoal = await getGoal(currentGoalId); + + if (!currentGoal) { + break; + } + + history.unshift({ goalID: currentGoal.id, title: currentGoal.title }); + currentGoalId = currentGoal.parentGoalId; + } + + return history; +}; + +export const moveGoalHierarchy = async (goalId: string, newParentGoalId: string) => { + const goalToMove = await getGoal(goalId); + const newParentGoal = await getGoal(newParentGoalId); + + if (!goalToMove) { + return; + } + + const oldParentId = goalToMove.parentGoalId; + const ancestors = await getGoalHistoryToRoot(goalId); + const ancestorGoalIds = ancestors.map((ele) => ele.goalID); + + const ancestorGoalsIdsOfNewParent = await getGoalHistoryToRoot(newParentGoalId); + const ancestorGoalIdsOfNewParent = ancestorGoalsIdsOfNewParent.map((ele) => ele.goalID); + + const ancestorGoals = await Promise.all(ancestorGoalIdsOfNewParent.map((id) => getGoal(id))); + const allParticipants = new Map(); + + [...ancestorGoals, newParentGoal].forEach((goal) => { + if (!goal?.participants) return; + goal.participants.forEach((participant) => { + if (participant.following) { + allParticipants.set(participant.relId, participant); + } + }); + }); + + goalToMove.participants.forEach((participant) => { + if (participant.following) { + allParticipants.set(participant.relId, participant); + } + }); + + const updatedGoal = { + ...goalToMove, + participants: Array.from(allParticipants.values()), + }; + + await createSharedGoal(updatedGoal, newParentGoalId, ancestorGoalIdsOfNewParent); + + await Promise.all([ + updateGoal(goalToMove.id, { + parentGoalId: newParentGoalId, + participants: updatedGoal.participants, + }), + removeGoalFromParentSublist(goalToMove.id, oldParentId), + addGoalToNewParentSublist(goalToMove.id, newParentGoalId), + updateRootGoal(goalToMove.id, newParentGoal?.rootGoalId ?? "root"), + ]); + + const descendants = await getAllDescendants(goalId); + await Promise.all( + descendants.map((descendant) => + updateGoal(descendant.id, { + participants: updatedGoal.participants, + rootGoalId: newParentGoal?.rootGoalId ?? "root", + }), + ), + ); + + const subscribers = await getParticipantsOfGoals(ancestorGoalIds); + + subscribers.forEach(async ({ sub, rootGoalId }) => { + await sendUpdatesToSubscriber(sub, rootGoalId, "moved", [ + { + level: ancestorGoalIds.length, + goal: { + ...updatedGoal, + parentGoalId: newParentGoalId, + rootGoalId, + }, + }, + ]); + }); + + if (descendants.length > 0) { + subscribers.forEach(async ({ sub, rootGoalId }) => { + await sendUpdatesToSubscriber( + sub, + rootGoalId, + "moved", + descendants.map((descendant) => ({ + level: ancestorGoalIds.length + 1, + goal: { + ...descendant, + participants: updatedGoal.participants, + rootGoalId, + }, + })), + ); + }); + } +}; diff --git a/src/helpers/GoalProcessor.ts b/src/helpers/GoalProcessor.ts index 228cb023f..85ef2e26a 100644 --- a/src/helpers/GoalProcessor.ts +++ b/src/helpers/GoalProcessor.ts @@ -112,6 +112,10 @@ export const getTypeAtPriority = (goalChanges: IChangesInGoal) => { typeAtPriority = "deleted"; } else if (goalChanges.restored.length > 0) { typeAtPriority = "restored"; + } else if (goalChanges.moved.length > 0) { + typeAtPriority = "moved"; + } else if (goalChanges.newGoalMoved.length > 0) { + typeAtPriority = "newGoalMoved"; } return { typeAtPriority }; }; @@ -129,22 +133,23 @@ export const jumpToLowestChanges = async (id: string, relId: string) => { const parentId = "id" in goalAtPriority ? goalAtPriority.id - : typeAtPriority === "subgoals" + : typeAtPriority === "subgoals" || typeAtPriority === "newGoalMoved" ? goalAtPriority.goal.parentGoalId : goalAtPriority.goal.id; if (typeAtPriority === "archived" || typeAtPriority === "deleted") { - return { typeAtPriority, parentId, goals: [await getGoal(parentId)] }; + const result = { typeAtPriority, parentId, goals: [await getGoal(parentId)] }; + return result; } - if (typeAtPriority === "subgoals") { - goalChanges.subgoals.forEach(({ intent, goal }) => { + if (typeAtPriority === "subgoals" || typeAtPriority === "newGoalMoved") { + goalChanges[typeAtPriority].forEach(({ intent, goal }) => { if (goal.parentGoalId === parentId) goals.push({ intent, goal }); }); } - if (typeAtPriority === "modifiedGoals") { + if (typeAtPriority === "modifiedGoals" || typeAtPriority === "moved") { let modifiedGoal = createGoalObjectFromTags({}); let goalIntent; - goalChanges.modifiedGoals.forEach(({ goal, intent }) => { + goalChanges[typeAtPriority].forEach(({ goal, intent }) => { if (goal.id === parentId) { modifiedGoal = { ...modifiedGoal, ...goal }; goalIntent = intent; @@ -153,16 +158,16 @@ export const jumpToLowestChanges = async (id: string, relId: string) => { goals = [{ intent: goalIntent, goal: modifiedGoal }]; } - return { + const result = { typeAtPriority, parentId, goals, }; + return result; } - } else { - console.log("inbox item doesn't exist"); } - return { typeAtPriority, parentId: "", goals: [] }; + const defaultResult = { typeAtPriority, parentId: "", goals: [] }; + return defaultResult; }; export const findGoalTagChanges = (goal1: GoalItem, goal2: GoalItem) => { diff --git a/src/helpers/InboxProcessor.ts b/src/helpers/InboxProcessor.ts index 4536bd700..38b4c5662 100644 --- a/src/helpers/InboxProcessor.ts +++ b/src/helpers/InboxProcessor.ts @@ -1,5 +1,5 @@ -import { GoalItem } from "@src/models/GoalItem"; -import { changesInGoal, InboxItem } from "@src/models/InboxItem"; +import { GoalItem, typeOfSub } from "@src/models/GoalItem"; +import { changesInGoal, changesInId, typeOfChange, typeOfIntent } from "@src/models/InboxItem"; import { getDefaultValueOfGoalChanges } from "@src/utils/defaultGenerators"; import { @@ -21,9 +21,38 @@ import { import { ITagChangesSchemaVersion, ITagsChanges } from "@src/Interfaces/IDisplayChangesModal"; import { fixDateVlauesInGoalObject } from "@src/utils"; import { getDeletedGoal, restoreUserGoal } from "@src/api/TrashAPI"; +import { getContactByRelId } from "@src/api/ContactsAPI"; +import { isIncomingGoalLatest } from "./mergeSharedGoalItems"; +import { getRootGoalId, updateRootGoalNotification } from "./GoalController"; + +export interface Payload { + relId: string; + lastProcessedTimestamp: string; + changeType: typeOfChange; + rootGoalId: string; + changes: (changesInGoal | changesInId)[]; + type: string; + timestamp: string; + TTL: number; +} + +const addChangesToRootGoal = async (goalId: string, relId: string, changes: any) => { + const rootGoalId = await getRootGoalId(goalId); + if (rootGoalId === "root") return; + + const inbox = await getInboxItem(rootGoalId); + if (!inbox) { + await createEmptyInboxItem(rootGoalId); + } + + await Promise.all([updateRootGoalNotification(rootGoalId), addGoalChangesInID(rootGoalId, relId, changes)]); +}; + +export const handleIncomingChanges = async (payload: Payload, relId: string) => { + console.log("Incoming change", payload); -export const handleIncomingChanges = async (payload, relId) => { if (payload.type === "sharer" && (await getSharedWMGoal(payload.rootGoalId))) { + console.log("Incoming change is a shared goal. Processing..."); const incGoal = await getSharedWMGoal(payload.rootGoalId); if (!incGoal || incGoal.participants.find((ele) => ele.relId === relId && ele.following) === undefined) { console.log("Changes ignored"); @@ -33,7 +62,14 @@ export const handleIncomingChanges = async (payload, relId) => { const changes = [ ...payload.changes.map((ele: changesInGoal) => ({ ...ele, goal: fixDateVlauesInGoalObject(ele.goal) })), ]; - await addGoalsInSharedWM([changes[0].goal]); + await addGoalsInSharedWM([changes[0].goal], relId); + } else if (payload.changeType === "newGoalMoved") { + const changes = [ + ...payload.changes.map((ele: changesInGoal) => ({ ...ele, goal: fixDateVlauesInGoalObject(ele.goal) })), + ]; + changes.map(async (ele) => { + await addGoalsInSharedWM([ele.goal], relId); + }); } else if (payload.changeType === "modifiedGoals") { const changes = [ ...payload.changes.map((ele: changesInGoal) => ({ ...ele, goal: fixDateVlauesInGoalObject(ele.goal) })), @@ -41,6 +77,7 @@ export const handleIncomingChanges = async (payload, relId) => { await updateSharedWMGoal(changes[0].goal.id, changes[0].goal); } else if (payload.changeType === "deleted") { const goalToBeDeleted = await getSharedWMGoal(payload.changes[0].id); + console.log("Deleting goal", goalToBeDeleted); await removeSharedWMChildrenGoals(goalToBeDeleted.id); await removeSharedWMGoal(goalToBeDeleted); if (goalToBeDeleted.parentGoalId !== "root") { @@ -64,21 +101,72 @@ export const handleIncomingChanges = async (payload, relId) => { } } } else if (["sharer", "suggestion"].includes(payload.type)) { - const { rootGoalId, changes, changeType } = payload; - const rootGoal = await getGoal(rootGoalId); - if (rootGoal) { - let inbox: InboxItem = await getInboxItem(rootGoalId); + const { changes, changeType, rootGoalId } = payload; + if (changeType === "subgoals" || changeType === "newGoalMoved") { + const rootGoal = await getGoal(rootGoalId); + if (!rootGoal || !rootGoal.participants.find((p) => p.relId === relId && p.following)) { + return; + } + + const contact = await getContactByRelId(relId); + + const goalsWithParticipants = changes.map((ele: changesInGoal) => ({ + ...ele, + goal: { + ...fixDateVlauesInGoalObject(ele.goal), + participants: [{ relId, following: true, type: "sharer" as typeOfSub, name: contact?.name || "" }], + }, + })); + + const inbox = await getInboxItem(rootGoalId); const defaultChanges = getDefaultValueOfGoalChanges(); - defaultChanges[changeType] = [...changes.map((ele) => ({ ...ele, intent: payload.type }))]; + defaultChanges[changeType] = [ + ...goalsWithParticipants.map((ele) => ({ ...ele, intent: payload.type as typeOfIntent })), + ]; + if (!inbox) { await createEmptyInboxItem(rootGoalId); - inbox = await getInboxItem(rootGoalId); } - await Promise.all([ - updateGoal(rootGoalId, { newUpdates: true }), - await addGoalChangesInID(rootGoalId, relId, defaultChanges), - ]); + + await addChangesToRootGoal(rootGoalId, relId, defaultChanges); + return; + } + + const goalId = "goal" in changes[0] ? changes[0].goal.id : changes[0].id; + const goal = await getGoal(goalId); + + if (!goal || !goal.participants.find((p) => p.relId === relId && p.following)) { + console.log("Goal not found or not shared with participant"); + return; + } + + let filteredChanges = changes; + if (changeType !== "deleted" && changeType !== "moved" && changeType !== "restored" && changeType !== "archived") { + const latestChanges = await Promise.all( + changes.map(async (ele) => { + const isLatest = await isIncomingGoalLatest(ele.goal.id, ele.goal); + return isLatest ? ele : null; + }), + ); + filteredChanges = latestChanges.filter((ele): ele is changesInGoal => ele !== null); + } + + if (filteredChanges.length === 0) { + return; } + + const inbox = await getInboxItem(goal.id); + const defaultChanges = getDefaultValueOfGoalChanges(); + defaultChanges[changeType] = filteredChanges.map((ele) => ({ + ...ele, + intent: payload.type, + })); + + if (!inbox) { + await createEmptyInboxItem(goal.id); + } + + await addChangesToRootGoal(goal.id, relId, defaultChanges); } }; @@ -86,7 +174,14 @@ export const acceptSelectedSubgoals = async (selectedGoals: GoalItem[], parentGo try { const childrens: string[] = []; selectedGoals.forEach(async (goal: GoalItem) => { - addGoal(fixDateVlauesInGoalObject({ ...goal, participants: [] })).catch((err) => console.log(err)); + const { relId } = goal.participants[0]; + const contact = await getContactByRelId(relId); + addGoal( + fixDateVlauesInGoalObject({ + ...goal, + participants: [{ relId, following: true, type: "sharer", name: contact?.name || "" }], + }), + ).catch((err) => console.log(err)); childrens.push(goal.id); }); await addIntoSublist(parentGoal.id, childrens); diff --git a/src/helpers/PartnerController.ts b/src/helpers/PartnerController.ts index 29ff5953b..5857d66ef 100644 --- a/src/helpers/PartnerController.ts +++ b/src/helpers/PartnerController.ts @@ -8,7 +8,7 @@ import { createGoalObjectFromTags } from "./GoalProcessor"; const sendUpdate = ( subscribers: IParticipant[], rootGoalId: string, - type: "subgoals" | "modifiedGoals", + type: "subgoals" | "modifiedGoals" | "newGoalMoved", obj: { level: number; goal: GoalItem; diff --git a/src/helpers/PubSubController.ts b/src/helpers/PubSubController.ts index a1c7ca09f..c17714947 100644 --- a/src/helpers/PubSubController.ts +++ b/src/helpers/PubSubController.ts @@ -22,7 +22,7 @@ export const sendUpdatedGoal = async ( .filter((ele) => !excludeSubs.includes(ele.sub.relId)) .forEach(async ({ sub, rootGoalId }) => { sendUpdatesToSubscriber(sub, rootGoalId, "modifiedGoals", [ - { level: ancestorGoalIds.length, goal: { ...changes, rootGoalId } }, + { level: ancestorGoalIds.length, goal: { ...changes, rootGoalId, participants: [] } }, ]).then(() => console.log("update sent")); }); } @@ -30,23 +30,34 @@ export const sendUpdatedGoal = async ( export const sendFinalUpdateOnGoal = async ( goalId: string, - action: "archived" | "deleted" | "restored", + action: "archived" | "deleted" | "restored" | "moved", ancestors: string[] = [], redefineAncestors = true, excludeSubs: string[] = [], ) => { + console.log(`[sendFinalUpdateOnGoal] Starting for goalId: ${goalId}, action: ${action}`); + const ancestorGoalIds = redefineAncestors ? (await getHistoryUptoGoal(goalId)).map((ele) => ele.goalID) : ancestors; + console.log("[sendFinalUpdateOnGoal] Ancestor IDs:", ancestorGoalIds); + const subscribers = await getParticipantsOfGoals(ancestorGoalIds); + console.log("[sendFinalUpdateOnGoal] Initial subscribers:", subscribers.length); + if (action === "restored") { - (await getParticipantsOfDeletedGoal(goalId)).forEach((doc) => { + const deletedGoalParticipants = await getParticipantsOfDeletedGoal(goalId); + console.log("[sendFinalUpdateOnGoal] Additional restored participants:", deletedGoalParticipants.length); + deletedGoalParticipants.forEach((doc) => { subscribers.push(doc); }); } - subscribers - .filter((ele) => !excludeSubs.includes(ele.sub.relId)) - .forEach(async ({ sub, rootGoalId }) => { - sendUpdatesToSubscriber(sub, rootGoalId, action, [{ level: ancestorGoalIds.length, id: goalId }]).then(() => - console.log("update sent"), - ); - }); + + const filteredSubscribers = subscribers.filter((ele) => !excludeSubs.includes(ele.sub.relId)); + console.log("[sendFinalUpdateOnGoal] Filtered subscribers:", filteredSubscribers.length); + + filteredSubscribers.forEach(async ({ sub, rootGoalId }) => { + console.log(`[sendFinalUpdateOnGoal] Sending update to subscriber ${sub.relId} for root goal ${rootGoalId}`); + sendUpdatesToSubscriber(sub, rootGoalId, action, [{ level: ancestorGoalIds.length, id: goalId }]) + .then(() => console.log(`[sendFinalUpdateOnGoal] Update sent successfully to ${sub.relId}`)) + .catch((error) => console.error(`[sendFinalUpdateOnGoal] Error sending update to ${sub.relId}:`, error)); + }); }; diff --git a/src/helpers/mergeSharedGoalItems.ts b/src/helpers/mergeSharedGoalItems.ts new file mode 100644 index 000000000..01a503b52 --- /dev/null +++ b/src/helpers/mergeSharedGoalItems.ts @@ -0,0 +1,16 @@ +import { getGoalById } from "@src/api/GoalsAPI"; +import { GoalItem } from "@src/models/GoalItem"; + +export async function isIncomingGoalLatest(localGoalId: string, incomingGoal: GoalItem): Promise { + const localGoal = await getGoalById(localGoalId); + + if (!localGoal) { + return true; + } + + if (incomingGoal.timestamp > localGoal.timestamp) { + return true; + } + + return false; +} diff --git a/src/hooks/useApp.tsx b/src/hooks/useApp.tsx index 0a4f4ecda..34f46523e 100644 --- a/src/hooks/useApp.tsx +++ b/src/hooks/useApp.tsx @@ -3,18 +3,20 @@ import { useEffect } from "react"; import { lastAction, displayConfirmation, openDevMode, languageSelectionState, displayToast } from "@src/store"; import { getTheme } from "@src/store/ThemeState"; -import { GoalItem } from "@src/models/GoalItem"; +import { GoalItem, IParticipant } from "@src/models/GoalItem"; import { checkMagicGoal, getAllLevelGoalsOfId, getGoal, updateSharedStatusOfGoal } from "@src/api/GoalsAPI"; import { addSharedWMGoal } from "@src/api/SharedWMAPI"; import { createDefaultGoals } from "@src/helpers/NewUserController"; import { refreshTaskCollection } from "@src/api/TasksAPI"; -import { handleIncomingChanges } from "@src/helpers/InboxProcessor"; +import { handleIncomingChanges, Payload } from "@src/helpers/InboxProcessor"; import { getContactSharedGoals, shareGoalWithContact } from "@src/services/contact.service"; import { updateAllUnacceptedContacts, getContactByRelId, clearTheQueue } from "@src/api/ContactsAPI"; import { useSetRecoilState, useRecoilValue, useRecoilState } from "recoil"; import { scheduledHintCalls } from "@src/api/HintsAPI/ScheduledHintCall"; import { LocalStorageKeys } from "@src/constants/localStorageKeys"; import { checkAndCleanupTrash } from "@src/api/TrashAPI"; +import ContactItem from "@src/models/ContactItem"; +import { SharedGoalMessage } from "@src/Interfaces/IContactMessages"; const langFromStorage = localStorage.getItem(LocalStorageKeys.LANGUAGE)?.slice(1, -1); const exceptionRoutes = ["/", "/invest", "/feedback", "/donate"]; @@ -29,6 +31,29 @@ function useApp() { const confirmationState = useRecoilValue(displayConfirmation); + const handleNewIncomingGoal = async (ele: SharedGoalMessage, contactItem: ContactItem, relId: string) => { + const { goalWithChildrens }: { goalWithChildrens: GoalItem[] } = ele; + const participant: IParticipant = { + name: contactItem.name, + relId, + type: "sharer", + following: true, + }; + try { + await Promise.all( + goalWithChildrens.map(async (goal) => { + const goalWithParticipant = { + ...goal, + participants: [participant], + }; + await addSharedWMGoal(goalWithParticipant); + }), + ); + } catch (error) { + console.error("[useApp] Error adding shared goals:", error); + } + }; + useEffect(() => { const init = async () => { updateAllUnacceptedContacts().then(async (contacts) => { @@ -69,7 +94,10 @@ function useApp() { const res = await getContactSharedGoals(); // @ts-ignore const resObject = res.response.reduce( - (acc, curr) => ({ ...acc, [curr.relId]: [...(acc[curr.relId] || []), curr] }), + (acc: { [key: string]: SharedGoalMessage[] }, curr) => ({ + ...acc, + [curr.relId]: [...(acc[curr.relId] || []), curr], + }), {}, ); if (res.success) { @@ -80,23 +108,9 @@ function useApp() { resObject[relId].forEach(async (ele) => { console.log("🚀 ~ file: useApp.tsx:45 ~ resObject[relId].forEach ~ ele:", ele); if (ele.type === "shareMessage") { - const { goalWithChildrens }: { goalWithChildrens: GoalItem[] } = ele; - const rootGoal = goalWithChildrens[0]; - rootGoal.participants.push({ - name: contactItem.name, - relId, - type: "sharer", - following: true, - }); - addSharedWMGoal(rootGoal) - .then(() => { - goalWithChildrens.slice(1).forEach((goal) => { - addSharedWMGoal(goal).catch((err) => console.log(`Failed to add in inbox ${goal.title}`, err)); - }); - }) - .catch((err) => console.log(`Failed to add root goal ${rootGoal.title}`, err)); + handleNewIncomingGoal(ele, contactItem, relId); } else if (["sharer", "suggestion"].includes(ele.type)) { - handleIncomingChanges(ele, relId).then(() => setLastAction("goalNewUpdates")); + handleIncomingChanges(ele as unknown as Payload, relId).then(() => setLastAction("goalNewUpdates")); } // else if (["suggestion", "shared", "collaboration", "collaborationInvite"].includes(ele.type)) { // let typeOfSub = ele.rootGoalId ? await findTypeOfSub(ele.rootGoalId) : "none"; diff --git a/src/hooks/useGoalActions.tsx b/src/hooks/useGoalActions.tsx index 20d0925e7..a30ec6970 100644 --- a/src/hooks/useGoalActions.tsx +++ b/src/hooks/useGoalActions.tsx @@ -1,7 +1,7 @@ import { getAllLevelGoalsOfId, unarchiveUserGoal, updateSharedStatusOfGoal } from "@src/api/GoalsAPI"; import { getSharedWMGoalById } from "@src/api/SharedWMAPI"; import { restoreUserGoal } from "@src/api/TrashAPI"; -import { createGoal, deleteGoal, deleteSharedGoal, modifyGoal } from "@src/helpers/GoalController"; +import { createGoal, createSharedGoal, deleteGoal, deleteSharedGoal, modifyGoal } from "@src/helpers/GoalController"; import { suggestChanges, suggestNewGoal } from "@src/helpers/PartnerController"; import { GoalItem } from "@src/models/GoalItem"; import { displayToast, lastAction, openDevMode } from "@src/store"; @@ -119,18 +119,31 @@ const useGoalActions = () => { } }; + const addSharedGoal = async (newGoal: GoalItem, parentGoalId: string) => { + await createSharedGoal(newGoal, parentGoalId, ancestors); + }; + const shareGoalWithRelId = async (relId: string, name: string, goal: GoalItem) => { const goalWithChildrens = await getAllLevelGoalsOfId(goal.id, true); + await shareGoalWithContact(relId, [ ...goalWithChildrens.map((ele) => ({ ...ele, participants: [], - parentGoalId: ele.id === goal.id ? "root" : ele.parentGoalId, + parentGoalId: ele.parentGoalId, rootGoalId: goal.id, })), ]); - updateSharedStatusOfGoal(goal.id, relId, name).then(() => console.log("status updated")); - showMessage(`Cheers!!, Your goal is shared with ${name}`); + + await Promise.all( + goalWithChildrens.map(async (goalItem) => { + await updateSharedStatusOfGoal(goalItem.id, relId, name); + }), + ).catch((error) => { + console.error("[shareGoalWithRelId] Error updating shared status:", error); + }); + + showMessage(`Cheers!! Your goal and its subgoals are shared with ${name}`); }; const addContact = async (relId: string, goalId: string) => { @@ -157,7 +170,6 @@ const useGoalActions = () => { goalTitle = `${goalTitle} copied!`; showMessage("Code copied to clipboard", goalTitle); }; - return { addGoal, deleteGoalAction, @@ -167,6 +179,7 @@ const useGoalActions = () => { shareGoalWithRelId, addContact, copyCode, + addSharedGoal, }; }; diff --git a/src/hooks/useGoalStore.tsx b/src/hooks/useGoalStore.tsx index 238e4a495..32cc82cf7 100644 --- a/src/hooks/useGoalStore.tsx +++ b/src/hooks/useGoalStore.tsx @@ -1,14 +1,16 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; -import { useRecoilValue } from "recoil"; +import { useRecoilValue, useSetRecoilState } from "recoil"; import { GoalItem } from "@src/models/GoalItem"; import { displayConfirmation } from "@src/store"; import { ILocationState } from "@src/Interfaces"; +import { moveGoalState } from "@src/store/moveGoalState"; const useGoalStore = () => { const { partnerId } = useParams(); const navigate = useNavigate(); const location = useLocation(); const showConfirmation = useRecoilValue(displayConfirmation); + const setGoalToMove = useSetRecoilState(moveGoalState); const openEditMode = (goal: GoalItem, customState?: ILocationState) => { const prefix = `${partnerId ? `/partners/${partnerId}/` : "/"}goals`; @@ -31,10 +33,16 @@ const useGoalStore = () => { navigate("/goals", { state: location.state }); }; + const handleMove = (goal: GoalItem) => { + setGoalToMove(goal); + navigate("/goals", { replace: true }); + }; + return { openEditMode, handleConfirmation, handleDisplayChanges, + handleMove, }; }; diff --git a/src/models/GoalItem.ts b/src/models/GoalItem.ts index 9471c4b96..38af50f8b 100644 --- a/src/models/GoalItem.ts +++ b/src/models/GoalItem.ts @@ -27,6 +27,7 @@ export interface GoalItem { language: string; link: string | null; participants: IParticipant[]; + isShared: boolean; rootGoalId: string; timeBudget?: { perDay: string; @@ -35,4 +36,5 @@ export interface GoalItem { typeOfGoal: "myGoal" | "shared"; category: TGoalCategory; newUpdates: boolean; + timestamp: number; } diff --git a/src/models/InboxItem.ts b/src/models/InboxItem.ts index d8e1358be..523153ad2 100644 --- a/src/models/InboxItem.ts +++ b/src/models/InboxItem.ts @@ -1,6 +1,13 @@ import { GoalItem } from "./GoalItem"; -export type typeOfChange = "subgoals" | "modifiedGoals" | "archived" | "deleted" | "restored"; +export type typeOfChange = + | "subgoals" + | "modifiedGoals" + | "archived" + | "deleted" + | "restored" + | "moved" + | "newGoalMoved"; export type typeOfIntent = "suggestion" | "shared"; export type changesInId = { level: number; id: string; intent: typeOfIntent }; @@ -12,6 +19,8 @@ export interface IChangesInGoal { archived: changesInId[]; deleted: changesInId[]; restored: changesInId[]; + moved: changesInId[]; + newGoalMoved: changesInGoal[]; } export interface InboxItem { diff --git a/src/models/db.ts b/src/models/db.ts index 6b5865a6a..d4c506531 100644 --- a/src/models/db.ts +++ b/src/models/db.ts @@ -49,6 +49,14 @@ export class ZinZenDB extends Dexie { console.log("🚀 ~ file: db.ts:63 ~ ZinZenDB ~ .upgrade ~ this.verno:", currentVersion); syncVersion(db, currentVersion); }); + this.goalsCollection.hook("updating", (modfications: GoalItem) => { + modfications.timestamp = Date.now(); + return modfications; + }); + + this.goalsCollection.hook("creating", (primKey, obj) => { + obj.timestamp = Date.now(); + }); } } diff --git a/src/models/dexie.ts b/src/models/dexie.ts index 423927085..c2e84d758 100644 --- a/src/models/dexie.ts +++ b/src/models/dexie.ts @@ -7,9 +7,9 @@ import { TaskItem } from "./TaskItem"; export const dbStoreSchema = { feelingsCollection: "++id, content, category, date, note", goalsCollection: - "id, category, title, duration, sublist, habit, on, start, due, afterTime, beforeTime, createdAt, parentGoalId, archived, participants, goalColor, language, link, rootGoalId, timeBudget, typeOfGoal", + "id, category, title, duration, sublist, habit, on, start, due, afterTime, beforeTime, createdAt, parentGoalId, archived, participants, goalColor, language, link, rootGoalId, isShared, timeBudget, typeOfGoal, timestamp", sharedWMCollection: - "id, category, title, duration, sublist, repeat, start, due, afterTime, beforeTime, createdAt, parentGoalId, participants, archived, goalColor, language, link, rootGoalId, timeBudget, typeOfGoal", + "id, category, title, duration, sublist, repeat, start, due, afterTime, beforeTime, createdAt, parentGoalId, participants, archived, goalColor, language, link, rootGoalId, isShared, timeBudget, typeOfGoal", contactsCollection: "id, name, relId, accepted, goalsToBeShared, createdAt, type", outboxCollection: null, inboxCollection: "id, goalChanges", diff --git a/src/pages/GoalsPage/MyGoals.tsx b/src/pages/GoalsPage/MyGoals.tsx index 4fb738aeb..d0e9c8504 100644 --- a/src/pages/GoalsPage/MyGoals.tsx +++ b/src/pages/GoalsPage/MyGoals.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, ChangeEvent, act } from "react"; +import React, { useState, useEffect, ChangeEvent, useRef } from "react"; import { useRecoilState, useRecoilValue } from "recoil"; import ZinZenTextLight from "@assets/images/LogoTextLight.svg"; @@ -34,6 +34,7 @@ import DeletedGoals from "./components/DeletedGoals"; import ArchivedGoals from "./components/ArchivedGoals"; import "./GoalsPage.scss"; +import MoveGoalGuide from "@components/GoalsComponents/MyGoal/MoveGoalGuide"; export const MyGoals = () => { let debounceTimeout: ReturnType; @@ -63,6 +64,8 @@ export const MyGoals = () => { const [action, setLastAction] = useRecoilState(lastAction); + const goalWrapperRef = useRef(null); + const getAllGoals = async () => { const [goals, delGoals] = await Promise.all([getActiveGoals("true"), getDeletedGoals("root")]); return { goals, delGoals }; @@ -127,7 +130,8 @@ export const MyGoals = () => { /> )} -
+
+ {parentId === "root" ? (
diff --git a/src/pages/GoalsPage/PartnerGoals.tsx b/src/pages/GoalsPage/PartnerGoals.tsx index 80f467f56..e62a9ab5b 100644 --- a/src/pages/GoalsPage/PartnerGoals.tsx +++ b/src/pages/GoalsPage/PartnerGoals.tsx @@ -56,6 +56,7 @@ const PartnerGoals = () => { const refreshActiveGoals = async () => { const rootGoals = await getRootGoalsOfPartner(relId); + console.log("rootGoals", rootGoals); handleUserGoals(rootGoals); }; diff --git a/src/pages/GoalsPage/components/ArchivedGoals.tsx b/src/pages/GoalsPage/components/ArchivedGoals.tsx index 610080cba..386834f8d 100644 --- a/src/pages/GoalsPage/components/ArchivedGoals.tsx +++ b/src/pages/GoalsPage/components/ArchivedGoals.tsx @@ -78,7 +78,6 @@ const ArchivedGoals = ({ goals }: { goals: GoalItem[] }) => { const [searchParams] = useSearchParams(); const { goal: activeGoal } = useActiveGoalContext(); const showOptions = !!searchParams.get("showOptions") && activeGoal?.archived === "true"; - console.log("🚀 ~ ArchivedGoals ~ showOptions:", showOptions); return ( <> diff --git a/src/services/contact.service.ts b/src/services/contact.service.ts index 93995f8a5..734d726f6 100644 --- a/src/services/contact.service.ts +++ b/src/services/contact.service.ts @@ -1,4 +1,5 @@ import { LocalStorageKeys } from "@src/constants/localStorageKeys"; +import { SharedGoalMessageResponse } from "@src/Interfaces/IContactMessages"; import { GoalItem, IParticipant } from "@src/models/GoalItem"; import { typeOfChange } from "@src/models/InboxItem"; import { createContactRequest, getInstallId } from "@src/utils"; @@ -43,7 +44,7 @@ export const collaborateWithContact = async (relId: string, goal: GoalItem) => { return res; }; -export const getContactSharedGoals = async () => { +export const getContactSharedGoals = async (): Promise => { const lastProcessedTimestamp = new Date( Number(localStorage.getItem(LocalStorageKeys.LAST_PROCESSED_TIMESTAMP)), ).toISOString(); @@ -54,7 +55,7 @@ export const getContactSharedGoals = async () => { ...(lastProcessedTimestamp ? { lastProcessedTimestamp } : {}), }); localStorage.setItem(LocalStorageKeys.LAST_PROCESSED_TIMESTAMP, `${Date.now()}`); - return res; + return res as SharedGoalMessageResponse; }; export const getRelationshipStatus = async (relationshipId: string) => { diff --git a/src/store/moveGoalState.ts b/src/store/moveGoalState.ts new file mode 100644 index 000000000..772022d52 --- /dev/null +++ b/src/store/moveGoalState.ts @@ -0,0 +1,7 @@ +import { GoalItem } from "@src/models/GoalItem"; +import { atom } from "recoil"; + +export const moveGoalState = atom({ + key: "moveGoalState", + default: null as GoalItem | null, +}); diff --git a/src/store/useNavigateToSubgoal.ts b/src/store/useNavigateToSubgoal.ts new file mode 100644 index 000000000..c0fb3e98f --- /dev/null +++ b/src/store/useNavigateToSubgoal.ts @@ -0,0 +1,36 @@ +import { useNavigate, useLocation, useParams } from "react-router-dom"; +import { ILocationState } from "@src/Interfaces"; +import { GoalItem } from "@src/models/GoalItem"; + +const useNavigateToSubgoal = () => { + const navigate = useNavigate(); + const location = useLocation(); + + const { partnerId } = useParams(); + const isPartnerModeActive = !!partnerId; + + const navigateToSubgoal = (goal: GoalItem | null) => { + if (!goal) { + return; + } + const newState: ILocationState = { + ...location.state, + activeGoalId: goal.id, + goalsHistory: [ + ...(location.state?.goalsHistory || []), + { + goalID: goal.id || "root", + goalColor: goal.goalColor || "#ffffff", + goalTitle: goal.title || "", + }, + ], + }; + const prefix = `${isPartnerModeActive ? `/partners/${partnerId}/` : "/"}goals`; + + navigate(`${prefix}/${goal.id}`, { state: newState, replace: true }); + }; + + return navigateToSubgoal; +}; + +export default useNavigateToSubgoal; diff --git a/src/utils/defaultGenerators.ts b/src/utils/defaultGenerators.ts index 4d71305e7..db7436adb 100644 --- a/src/utils/defaultGenerators.ts +++ b/src/utils/defaultGenerators.ts @@ -1,6 +1,7 @@ import { v5 as uuidv5 } from "uuid"; import { GoalItem } from "@src/models/GoalItem"; +import { changesInGoal, changesInId } from "@src/models/InboxItem"; import { myNameSpace } from "."; export const createPollObject = (goal: GoalItem, params: object = {}) => ({ @@ -23,10 +24,12 @@ export const createPollObject = (goal: GoalItem, params: object = {}) => ({ export function getDefaultValueOfGoalChanges() { return { - subgoals: [], - modifiedGoals: [], - archived: [], - deleted: [], - restored: [], + subgoals: [] as changesInGoal[], + modifiedGoals: [] as changesInGoal[], + archived: [] as changesInId[], + deleted: [] as changesInId[], + restored: [] as changesInId[], + moved: [] as changesInId[], + newGoalMoved: [] as changesInGoal[], }; }