+ const DecisionCard = ({ decision, showEditButton = true, organisationId }: DecisionCardProps) => {
+ return (
+
-
- {items.map((item) => (
-
-
-
- ))}
-
+
+
{decision.title}
+
+ {decision.cost}
+
+
+ {decision.decision && (
+
+ Decision: {decision.decision}
+
+ )}
+
+
+
+
+
+
+ {decision.stakeholders.length} stakeholders
+
+
+
+ Updated {decision.updatedAt?.toLocaleDateString() || decision.createdAt.toLocaleDateString()}
+
+
+
+
+ {showEditButton ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
handleDelete(decision.id)}
+ >
+
+
-
- {/* Charts row */}
-
+ );
+ };
+
+ const DecisionGroup = ({ title, decisions }: { title: string, decisions: Decision[] }) => {
+ if (statusFilter !== 'all' && !decisions.length) return null;
+
+ return (
+
+
{title}
+
+ {decisions.map((decision) => (
+
+ ))}
+ {statusFilter === 'all' && !decisions.length && (
+
No {title.toLowerCase()} decisions
+ )}
+ );
+ };
+
+ return (
+
+
+ {selectedOrganisation.name} 's Decisions
+
+
+ {/* Filters and Search Bar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 h-10"
+ />
+
+
+
+
+
+
+ All Statuses
+ In Progress
+ Blocked
+ Superseded
+ Published
+
+
+
setSortOrder(value as SortOrder)}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {filteredAndSortedDecisions.length === 0 ? (
+
+ No decisions found matching your criteria
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
- )
+ );
}
diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/decide/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/decide/page.tsx
deleted file mode 100644
index 5e48727..0000000
--- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/decide/page.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-'use client'
-
-import { useParams } from 'next/navigation'
-import { useDecision } from '@/hooks/useDecisions'
-import Link from 'next/link'
-import { Button } from '@/components/ui/button'
-import { Card } from '@/components/ui/card'
-import { Editor } from '@/components/editor'
-import { DecisionItemList } from '@/components/decision-item-list'
-import { SupportingMaterialsList } from '@/components/supporting-materials-list'
-import { DecisionRelationshipsList } from '@/components/decision-relationships-list'
-
-export default function DecidePage() {
- const params = useParams()
- const decisionId = params.id as string
- const projectId = params.projectId as string
- const teamId = params.teamId as string
- const organisationId = params.organisationId as string
-
- const {
- decision,
- loading: decisionsLoading,
- error: decisionsError,
- updateDecisionOptions,
- updateDecisionCriteria,
- updateDecisionContent,
- addSupportingMaterial,
- removeSupportingMaterial,
- } = useDecision(decisionId)
-
- if (decisionsLoading) {
- return
Loading...
- }
-
- if (decisionsError) {
- return
Error: {decisionsError.message}
- }
-
- if (!decision) {
- return
Decision not found
- }
-
- const handleAddOption = (option: string) => {
- const newOptions = [...decision.options.filter(o => o !== ""), option]
- updateDecisionOptions(newOptions)
- }
-
- const handleUpdateOption = (index: number, option: string) => {
- const newOptions = [...decision.options]
- newOptions[index] = option
- updateDecisionOptions(newOptions)
- }
-
- const handleDeleteOption = (index: number) => {
- const newOptions = decision.options.filter((_, i) => i !== index)
- updateDecisionOptions(newOptions)
- }
-
- const handleAddCriterion = (criterion: string) => {
- const newCriteria = [...decision.criteria.filter(c => c !== ""), criterion]
- updateDecisionCriteria(newCriteria)
- }
-
- const handleUpdateCriterion = (index: number, criterion: string) => {
- const newCriteria = [...decision.criteria]
- newCriteria[index] = criterion
- updateDecisionCriteria(newCriteria)
- }
-
- const handleDeleteCriterion = (index: number) => {
- const newCriteria = decision.criteria.filter((_, i) => i !== index)
- updateDecisionCriteria(newCriteria)
- }
-
- return (
- <>
-
Decide
-
-
-
-
-
-
-
-
-
-
-
Decision
- updateDecisionContent(content)}
- />
-
-
-
-
-
-
-
-
- Publish
-
-
-
- >
- )
-}
-
diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/identify/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/identify/page.tsx
deleted file mode 100644
index 2be0a83..0000000
--- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/identify/page.tsx
+++ /dev/null
@@ -1,511 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Card } from "@/components/ui/card";
-import { Label } from "@/components/ui/label";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-import { Checkbox } from "@/components/ui/checkbox";
-import {
- Bold,
- Italic,
- Heading,
- Quote,
- List,
- ListOrdered,
- Link as LinkIcon,
- Image as ImageIcon,
- Eye,
- Book,
- Maximize,
- HelpCircle,
- ChevronDown,
- ChevronRight,
-} from "lucide-react";
-import Link from "next/link";
-import { useParams } from "next/navigation";
-import { useDecision } from "@/hooks/useDecisions";
-import { useStakeholders } from "@/hooks/useStakeholders";
-import { useStakeholderTeams } from "@/hooks/useStakeholderTeams";
-import { useOrganisations } from "@/hooks/useOrganisations";
-import {
- Cost,
- Reversibility,
-} from "@/lib/domain/Decision";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Stakeholder } from "@/lib/domain/Stakeholder";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from "@/components/ui/command";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { DecisionRelationshipsList } from '@/components/decision-relationships-list'
-
-interface StakeholderGroupProps {
- teamName: string;
- stakeholders: Stakeholder[];
- isExpanded: boolean;
- onToggle: () => void;
- selectedStakeholderIds: string[];
- onStakeholderChange: (stakeholderId: string, checked: boolean) => void;
-}
-
-function StakeholderGroup({
- teamName,
- stakeholders,
- isExpanded,
- onToggle,
- selectedStakeholderIds,
- onStakeholderChange,
-}: StakeholderGroupProps) {
- return (
-
-
- {teamName}
- {isExpanded ? (
-
- ) : (
-
- )}
-
- {isExpanded && (
-
-
- {stakeholders.map((stakeholder) => (
-
-
- onStakeholderChange(stakeholder.id, checked as boolean)
- }
- />
-
-
-
-
- {stakeholder.displayName
- ? stakeholder.displayName
- .split(" ")
- .map((n) => n[0])
- .join("")
- : "?"}
-
-
-
- {stakeholder.displayName}
-
-
-
- ))}
-
-
- )}
-
- );
-}
-
-export default function DecisionIdentityPage() {
- const params = useParams();
- const decisionId = params.id as string;
- const projectId = params.projectId as string;
- const teamId = params.teamId as string;
- const organisationId = params.organisationId as string;
-
- const {
- decision,
- loading: decisionsLoading,
- error: decisionsError,
- updateDecisionTitle,
- updateDecisionDescription,
- updateDecisionCost,
- updateDecisionReversibility,
- updateDecisionDriver,
- addStakeholder,
- removeStakeholder,
- } = useDecision(decisionId);
-
- const {
- stakeholders,
- loading: stakeholdersLoading,
- } = useStakeholders();
- const { stakeholderTeams, loading: stakeholderTeamsLoading } =
- useStakeholderTeams();
- const { organisations, loading: organisationsLoading } = useOrganisations();
- const [expandedTeams, setExpandedTeams] = useState
([teamId]); // Current team is expanded by default
- const [driverOpen, setDriverOpen] = useState(false);
-
- const currentOrg = organisations?.find((org) => org.id === organisationId);
-
- if (
- decisionsLoading ||
- stakeholdersLoading ||
- stakeholderTeamsLoading ||
- organisationsLoading
- ) {
- return Loading...
- }
-
- if (decisionsError) {
- return Error: {(decisionsError)?.message}
- }
-
- if (!decision || !currentOrg) {
- return Decision or organisation not found
;
- }
-
- const handleStakeholderChange = (stakeholderId: string, checked: boolean) => {
- if (checked) {
- addStakeholder(stakeholderId);
- } else {
- removeStakeholder(stakeholderId);
- }
- };
-
- const toggleTeam = (teamId: string) => {
- setExpandedTeams((prev) =>
- prev.includes(teamId)
- ? prev.filter((id) => id !== teamId)
- : [...prev, teamId],
- );
- };
-
- // Group stakeholders by team
- const stakeholdersByTeam = currentOrg.teams.reduce(
- (acc, team) => {
- const teamStakeholderIds = stakeholderTeams
- .filter((st) => st.teamId === team.id)
- .map((st) => st.stakeholderId);
-
- const teamStakeholders = stakeholders.filter((s) =>
- teamStakeholderIds.includes(s.id),
- );
-
- if (teamStakeholders.length > 0) {
- acc[team.id] = {
- name: team.name,
- stakeholders: teamStakeholders,
- };
- }
-
- return acc;
- },
- {} as Record,
- );
-
- // Get unique stakeholders for the organization
- const uniqueOrgStakeholders = Array.from(
- new Map(
- Object.values(stakeholdersByTeam)
- .flatMap(({ stakeholders }) => stakeholders)
- .map((stakeholder) => [stakeholder.id, stakeholder]),
- ).values(),
- ).sort((a, b) => a.displayName.localeCompare(b.displayName));
-
- return (
- <>
-
-
- Identify the Decision
-
-
- Capture information about the decision being made and who is involved
-
-
-
-
-
-
-
- Driver
-
-
-
-
-
- {decision.driverStakeholderId ? (
- (() => {
- const driverStakeholder = uniqueOrgStakeholders.find(
- (s) => s.id === decision.driverStakeholderId,
- );
- return (
- <>
-
-
-
-
- {driverStakeholder?.displayName
- ?.split(" ")
- .map((n) => n[0])
- .join("") || "?"}
-
-
- {driverStakeholder?.displayName}
-
-
- >
- );
- })()
- ) : (
- <>
- Select driver...
-
- >
- )}
-
-
-
-
-
- No stakeholder found.
-
- {uniqueOrgStakeholders.map((stakeholder) => (
- {
- updateDecisionDriver(stakeholder.id);
- setDriverOpen(false);
- }}
- >
-
-
-
- {stakeholder.displayName
- ? stakeholder.displayName
- .split(" ")
- .map((n) => n[0])
- .join("")
- : "?"}
-
-
- {stakeholder.displayName}
-
- ))}
-
-
-
-
-
-
-
-
-
- Title
-
- updateDecisionTitle(e.target.value)}
- className="flex-1"
- />
-
-
-
-
-
-
-
-
Details
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cost
-
- - how much will it cost (in effort, time or money) to implement?
-
-
-
- updateDecisionCost(value as Cost)
- }
- >
-
-
- Low
-
-
-
- Medium
-
-
-
- High
-
-
-
-
-
-
-
- Reversibility
-
-
- - like choosing a
-
-
-
- updateDecisionReversibility(value as Reversibility)
- }
- >
-
-
- Hat
-
-
-
- Haircut
-
-
-
- Tattoo
-
-
-
-
-
-
-
- Stakeholders
-
-
- - who has an interest in - or is impacted by - this decision?
-
-
-
- {Object.entries(stakeholdersByTeam)
- .sort(([id1], [id2]) => {
- // Put the current team first
- if (id1 === teamId) return -1;
- if (id2 === teamId) return 1;
- return 0;
- })
- .map(([teamId, { name, stakeholders }]) => (
- toggleTeam(teamId)}
- selectedStakeholderIds={decision.decisionStakeholderIds}
- onStakeholderChange={handleStakeholderChange}
- />
- ))}
-
-
-
-
-
-
-
- Next
-
-
- >
- );
-}
diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/layout.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/layout.tsx
deleted file mode 100644
index 24d22c5..0000000
--- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/layout.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-'use client'
-
-import { ReactNode } from 'react'
-import { usePathname } from 'next/navigation'
-import WorkflowProgress from '@/components/workflow-progress'
-
-export default function DecisionLayout({
- children
-}: {
- children: ReactNode
-}) {
- const pathname = usePathname()
-
- const getCurrentStep = () => {
- if (pathname.endsWith('/identify')) return 1
- if (pathname.endsWith('/process')) return 2
- if (pathname.endsWith('/decide')) return 3
- if (pathname.endsWith('/view')) return 4
- return 1
- }
-
- const showWorkflowProgress = !pathname.endsWith('/view')
-
- return (
-
-
- {children}
-
-
- {showWorkflowProgress && (
-
-
-
- )}
-
- )
-}
\ No newline at end of file
diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/process/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/process/page.tsx
deleted file mode 100644
index 7bf3d7d..0000000
--- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/process/page.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-"use client"
-
-import { useState, useEffect } from "react"
-import { Button } from "@/components/ui/button"
-import { RoleAssignment } from "@/components/role-assignment"
-import { DecisionMethodCard } from "@/components/decision-method-card"
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
-import Link from "next/link"
-import { useParams } from 'next/navigation'
-import { useDecision } from "@/hooks/useDecisions"
-import { useStakeholders } from "@/hooks/useStakeholders"
-import { DecisionMethod } from "@/lib/domain/Decision"
-
-export default function DecisionProcess() {
- const params = useParams()
- const decisionId = params.id as string
- const projectId = params.projectId as string
- const teamId = params.teamId as string
- const organisationId = params.organisationId as string
-
- const {
- decision,
- loading: decisionsLoading,
- error: decisionsError,
- updateDecisionMethod
- } = useDecision(decisionId)
- const {
- loading: stakeholdersLoading,
- } = useStakeholders()
-
- const [selectedMethod, setSelectedMethod] = useState("consent")
-
- useEffect(() => {
- if (decision?.decisionMethod) {
- setSelectedMethod(decision.decisionMethod as DecisionMethod)
- }
- }, [decision?.decisionMethod])
-
- if (
- decisionsLoading ||
- stakeholdersLoading
- ) {
- return Loading...
- }
-
- if (decisionsError) {
- return Error: {decisionsError.message}
- }
-
- if (!decision) {
- return Decision not found
- }
-
- const handleMethodSelect = (method: DecisionMethod) => {
- setSelectedMethod(method)
- updateDecisionMethod(method)
- }
-
- return (
- <>
-
-
- Decision making method
-
- Given the assigned roles assigned; one of the following methods could be used:
-
-
-
-
- handleMethodSelect("accountable_individual")}
- />
- handleMethodSelect("consent")}
- />
-
-
-
-
-
-
-
-
-
- Next
-
-
-
- >
- )
-}
-
diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/view/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/view/page.tsx
deleted file mode 100644
index 00b865b..0000000
--- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/view/page.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-'use client'
-
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
-import { Button } from "@/components/ui/button"
-import Link from 'next/link'
-import { useParams } from 'next/navigation'
-import { useDecision } from '@/hooks/useDecisions'
-import { useStakeholders } from '@/hooks/useStakeholders'
-import { SupportingMaterialIcon } from '@/components/supporting-material-icon'
-import { StakeholderRole } from '@/lib/domain/Decision'
-
-function StakeholderGroup({ title, stakeholders }: { title: string, stakeholders: { id: string, displayName: string, photoURL?: string }[] }) {
- return (
-
-
{title}
-
- {stakeholders.map((stakeholder) => (
-
-
-
- {stakeholder.displayName[0]}
-
-
{stakeholder.displayName}
-
- ))}
-
-
- )
-}
-
-export default function DecisionView() {
- const params = useParams()
- const decisionId = params.id as string
- const projectId = params.projectId as string
- const teamId = params.teamId as string
- const organisationId = params.organisationId as string
-
- const { decision, loading, error } = useDecision(decisionId)
- const { stakeholders } = useStakeholders()
-
- if (loading) {
- return Loading...
- }
-
- if (error) {
- return Error: {error.message}
- }
-
- if (!decision) {
- return Decision not found
- }
-
- const deciderStakeholders = stakeholders?.filter(s =>
- decision.stakeholders.find(ds => ds.stakeholder_id === s.id && ds.role === ('decider' as StakeholderRole))
- ) || []
- const consultedStakeholders = stakeholders?.filter(s =>
- decision.stakeholders.find(ds => ds.stakeholder_id === s.id && ds.role === ('consulted' as StakeholderRole))
- ) || []
- const informedStakeholders = stakeholders?.filter(s =>
- decision.stakeholders.find(ds => ds.stakeholder_id === s.id && ds.role === ('informed' as StakeholderRole))
- ) || []
-
- return (
- <>
-
-
-
{decision.title || 'Untitled Decision'}
-
{decision.description}
-
-
-
- Decision
- {decision.decision || 'No decision made yet'}
-
-
- {decision.options.length > 0 && (
-
- Options considered
-
- {decision.options.map((option, index) => (
- {option}
- ))}
-
-
- )}
-
- {decision.criteria.length > 0 && (
-
- Criteria
-
- {decision.criteria.map((criterion, index) => (
- {criterion}
- ))}
-
-
- )}
-
- {decision.supportingMaterials.length > 0 && (
-
- Supporting Materials
-
- {decision.supportingMaterials.map((material, index) => (
-
- ))}
-
-
- )}
-
-
- Method
- {decision.decisionMethod || 'No method selected'}
-
-
-
- Stakeholders
-
- {deciderStakeholders.length > 0 && (
-
- )}
- {consultedStakeholders.length > 0 && (
-
- )}
- {informedStakeholders.length > 0 && (
-
- )}
-
-
-
- {decision.status === 'published' && (
-
-
This decision has been published and can no longer be edited
-
-
- Un-publish
-
-
-
- )}
-
- >
- )
-}
-
diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/page.tsx
deleted file mode 100644
index a0aad95..0000000
--- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/page.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-'use client'
-
-import { Button } from '@/components/ui/button'
-import Link from 'next/link'
-import { useProjectDecisions } from '@/hooks/useProjectDecisions'
-import { Card } from '@/components/ui/card'
-import { PlusCircle } from 'lucide-react'
-import { useParams } from 'next/navigation'
-
-export default function DecisionsPage() {
- const { decisions } = useProjectDecisions()
- const params = useParams()
-
- return (
-
-
-
Decisions
-
-
-
- New Decision
-
-
-
-
-
- {decisions?.map((decision) => (
-
-
- {decision.title || 'Untitled Decision'}
- {decision.description || 'No description'}
-
- Status: {decision.status}
- Cost: {decision.cost}
- Reversibility: {decision.reversibility}
-
-
-
- ))}
-
-
- )
-}
\ No newline at end of file
diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/page.tsx
deleted file mode 100644
index 6cf7c59..0000000
--- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/page.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-'use client'
-
-import { useProjectDecisions } from '@/hooks/useProjectDecisions';
-import { Button } from "@/components/ui/button"
-import { Pencil, Trash2, FileText, Users, Clock } from 'lucide-react'
-import Link from 'next/link'
-import { useParams } from 'next/navigation';
-import { WorkflowProgress } from '@/components/ui/workflow-progress';
-import { Decision } from '@/lib/domain/Decision';
-
-interface DecisionCardProps {
- decision: Decision;
- showEditButton?: boolean;
-}
-
-export default function ProjectDecisionsPage() {
- const params = useParams();
- const { decisions, loading, error, deleteDecision } = useProjectDecisions();
-
- if (loading) {
- return Loading decisions...
;
- }
-
- if (error) {
- return Error loading decisions: {error.message}
;
- }
-
- const inProgressDecisions = decisions?.filter(d => d.status === 'in_progress') || [];
- const blockedDecisions = decisions?.filter(d => d.status === 'blocked') || [];
- const supersededDecisions = decisions?.filter(d => d.status === 'superseded') || [];
- const publishedDecisions = decisions?.filter(d => d.status === 'published') || [];
-
- const handleDelete = async (decisionId: string) => {
- try {
- await deleteDecision(decisionId);
- console.log('Decision deleted:', decisionId);
- } catch (error) {
- console.error('Error deleting decision:', error);
- }
- };
-
- const DecisionCard = ({ decision, showEditButton = true }: DecisionCardProps) => (
-
-
-
-
{decision.title}
-
- {decision.cost}
-
-
- {decision.decision && (
-
- Decision: {decision.decision}
-
- )}
-
-
-
-
-
-
- {decision.stakeholders.length} stakeholders
-
-
-
- Updated {decision.updatedAt?.toLocaleDateString() || decision.createdAt.toLocaleDateString()}
-
-
-
-
- {showEditButton ? (
-
-
-
-
-
- ) : (
-
-
-
-
-
- )}
-
handleDelete(decision.id)}
- >
-
-
-
-
- );
-
- return (
-
-
-
-
- 🌟 Start new decision
-
-
-
-
-
-
-
In Progress
-
- {inProgressDecisions.map((decision) => (
-
- ))}
-
-
-
- {blockedDecisions.length > 0 && (
-
-
Blocked
-
- {blockedDecisions.map((decision) => (
-
- ))}
-
-
- )}
-
- {publishedDecisions.length > 0 && (
-
-
Published
-
- {publishedDecisions.map((decision) => (
-
- ))}
-
-
- )}
-
- {supersededDecisions.length > 0 && (
-
-
Superseded
-
- {supersededDecisions.map((decision) => (
-
- ))}
-
-
- )}
-
-
- );
-}
-
diff --git a/app/organisation/page.tsx b/app/organisation/page.tsx
index 907cd80..6127f58 100644
--- a/app/organisation/page.tsx
+++ b/app/organisation/page.tsx
@@ -9,116 +9,48 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Plus } from "lucide-react";
-import { useStakeholders } from "@/hooks/useStakeholders";
-import { useStakeholderTeams } from "@/hooks/useStakeholderTeams";
-import { OrgSection } from "@/components/org-section";
-import { StakeholderSection } from "@/components/stakeholder-section";
-import { Organisation } from "@/lib/domain/Organisation";
-import { Team } from "@/lib/domain/Team";
+import Link from "next/link";
+import { redirect } from "next/navigation";
export default function OrganisationPage() {
- const { organisations, setOrganisations, addOrganisation } =
- useOrganisations();
- const { stakeholders, addStakeholder, updateStakeholder, removeStakeholder } = useStakeholders();
- const { stakeholderTeams, setStakeholderTeams, addStakeholderTeam, removeStakeholderTeam } =
- useStakeholderTeams();
+ const { organisations, loading, error } = useOrganisations();
- const handleAddOrg = () => {
- addOrganisation({
- id: "new",
- name: "new Org",
- teams: [],
- });
- };
+ if (loading) {
+ return Loading...
;
+ }
- const handleAddStakeholder = () => {
- addStakeholder({
- id: "new",
- displayName: "",
- email: "",
- photoURL: "",
- });
- };
+ if (error) {
+ return Error: {error.message}
;
+ }
- const handleAddTeamToOrg = (orgId: string) => {
- setOrganisations(
- organisations.map((org) => {
- if (org.id === orgId) {
- return Organisation.create({
- ...org,
- teams: [
- ...org.teams,
- Team.create({
- id: "new",
- name: "new Team",
- projects: [],
- organisation: {
- id: org.id,
- name: org.name,
- },
- }),
- ],
- });
- }
- return org;
- }),
- );
- };
+ // Auto-redirect if user only has access to one organisation
+ if (organisations.length === 1) {
+ redirect(`/organisation/${organisations[0].id}`);
+ return null;
+ }
return (
-
-
-
-
-
- Organisations & Teams
-
-
- Manage your organization and team structure
-
-
-
-
- Add Organisation
-
-
-
-
-
-
-
-
-
-
- Stakeholders
-
- Manage stakeholders and their team memberships
-
-
-
-
- Add Stakeholder
-
-
-
-
-
-
-
+
+
+ Your Organisations
+
+ Select an organisation to view its details
+
+
+
+
+ {organisations.map((org) => (
+
+
+ {org.name}
+
+
+ ))}
+
+
+
);
}
diff --git a/app/unauthorized/page.tsx b/app/unauthorized/page.tsx
new file mode 100644
index 0000000..1b51d04
--- /dev/null
+++ b/app/unauthorized/page.tsx
@@ -0,0 +1,26 @@
+import Link from 'next/link';
+
+export default function UnauthorizedPage() {
+ return (
+
+
+
+
+ Unauthorized Access
+
+
+ You don't have permission to access this page.
+
+
+
+
+ Return to Home
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apphosting.prod.yaml b/apphosting.prod.yaml
new file mode 100644
index 0000000..93300ec
--- /dev/null
+++ b/apphosting.prod.yaml
@@ -0,0 +1,42 @@
+##################################
+# Overrides for prod environment #
+##################################
+# Settings for Backend (on Cloud Run).
+# See https://firebase.google.com/docs/app-hosting/configure#cloud-run
+runConfig:
+ minInstances: 0
+ # maxInstances: 100
+ # concurrency: 80
+ # cpu: 1
+ # memoryMiB: 512
+
+# Environment variables and secrets.
+env:
+ # Configure environment variables.
+ # See https://firebase.google.com/docs/app-hosting/configure#user-defined-environment
+ - variable: ENVIRONMENT
+ value: production
+ availability:
+ - BUILD
+ - RUNTIME
+
+ # Specify which Firestore database to use
+ - variable: NEXT_PUBLIC_FIREBASE_FIRESTORE_DATABASE_ID
+ value: decision-copilot-prod
+ availability:
+ - BUILD
+ - RUNTIME
+
+ - variable: NEXT_PUBLIC_BASE_URL
+ value: https://decision-copilot.wellmaintained.org
+ availability:
+ - BUILD
+ - RUNTIME
+
+ - variable: ADMIN_USERS
+ value: mrdavidlaing@gmail.com,david@mechanical-orchard.com,david@castlelaing.com
+ availability:
+ - BUILD
+ - RUNTIME
+
+ # DO NOT set NODE_ENV here - let it default to 'production' for builds
diff --git a/apphosting.staging.yaml b/apphosting.staging.yaml
new file mode 100644
index 0000000..ecdd996
--- /dev/null
+++ b/apphosting.staging.yaml
@@ -0,0 +1,48 @@
+# ####################################
+# Overrides for staging environment #
+#####################################
+# Settings for Backend (on Cloud Run).
+# See https://firebase.google.com/docs/app-hosting/configure#cloud-run
+runConfig:
+ minInstances: 0
+ # maxInstances: 100
+ # concurrency: 80
+ # cpu: 1
+ # memoryMiB: 512
+
+# Environment variables and secrets.
+env:
+ # Configure environment variables.
+ # See https://firebase.google.com/docs/app-hosting/configure#user-defined-environment
+ - variable: ENVIRONMENT
+ value: staging
+ availability:
+ - BUILD
+ - RUNTIME
+
+ # Specify which Firestore database to use
+ - variable: NEXT_PUBLIC_FIREBASE_FIRESTORE_DATABASE_ID
+ value: decision-copilot-prod
+ availability:
+ - BUILD
+ - RUNTIME
+
+ # Override the default `pnpm build` command run during the build process
+ - variable: GOOGLE_NODE_RUN_SCRIPTS
+ value: build:profile
+ availability:
+ - BUILD
+
+ - variable: NEXT_PUBLIC_BASE_URL
+ value: https://decision-copilot.staging.wellmaintained.org
+ availability:
+ - BUILD
+ - RUNTIME
+
+ - variable: ADMIN_USERS
+ value: mrdavidlaing@gmail.com,david@mechanical-orchard.com,david@castlelaing.com
+ availability:
+ - BUILD
+ - RUNTIME
+
+ # DO NOT set NODE_ENV here - let it default to 'production' for builds
diff --git a/apphosting.yaml b/apphosting.yaml
new file mode 100644
index 0000000..0e726c6
--- /dev/null
+++ b/apphosting.yaml
@@ -0,0 +1,24 @@
+# Settings for Backend (on Cloud Run).
+# See https://firebase.google.com/docs/app-hosting/configure#cloud-run
+runConfig:
+ minInstances: 0
+ # maxInstances: 100
+ # concurrency: 80
+ # cpu: 1
+ # memoryMiB: 512
+
+# Environment variables and secrets.
+# env:
+ # Configure environment variables.
+ # See https://firebase.google.com/docs/app-hosting/configure#user-defined-environment
+ # - variable: MESSAGE
+ # value: Hello world!
+ # availability:
+ # - BUILD
+ # - RUNTIME
+
+ # Grant access to secrets in Cloud Secret Manager.
+ # See https://firebase.google.com/docs/app-hosting/configure#secret-parameters
+ # - variable: MY_SECRET
+ # secret: mySecretRef
+
diff --git a/components/AdminRoute.tsx b/components/AdminRoute.tsx
new file mode 100644
index 0000000..0029647
--- /dev/null
+++ b/components/AdminRoute.tsx
@@ -0,0 +1,35 @@
+import { useRouter } from 'next/navigation';
+import { useAuth } from '@/hooks/useAuth';
+import { useEffect } from 'react';
+
+interface AdminRouteProps {
+ children: React.ReactNode;
+ loadingComponent?: React.ReactNode;
+}
+
+/**
+ * Higher-order component that protects admin routes
+ * Redirects to /unauthorized if user is not an admin
+ */
+export function AdminRoute({
+ children,
+ loadingComponent = Loading...
+}: AdminRouteProps) {
+ const { user, loading, isAdmin } = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ // Only redirect after auth state is determined
+ if (!loading && (!user || !isAdmin)) {
+ router.push('/unauthorized');
+ }
+ }, [user, loading, isAdmin, router]);
+
+ // Show loading state while checking auth
+ if (loading || !user || !isAdmin) {
+ return loadingComponent;
+ }
+
+ // Render children only for admin users
+ return <>{children}>;
+}
\ No newline at end of file
diff --git a/components/StakeholderManagement.tsx b/components/StakeholderManagement.tsx
new file mode 100644
index 0000000..bad6b30
--- /dev/null
+++ b/components/StakeholderManagement.tsx
@@ -0,0 +1,428 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useForm } from 'react-hook-form'
+import { Stakeholder } from '@/lib/domain/Stakeholder'
+import { useStakeholders } from '@/hooks/useStakeholders'
+import { useTeamHierarchy } from '@/hooks/useTeamHierarchy'
+import { useStakeholderTeams } from '@/hooks/useStakeholderTeams'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Pencil, Trash2, UserPlus, ChevronDown, ChevronRight } from 'lucide-react'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { ControllerRenderProps } from 'react-hook-form'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { TeamHierarchyNode } from '@/lib/domain/TeamHierarchy'
+
+interface StakeholderFormData {
+ displayName: string
+ email: string
+ photoURL?: string
+}
+
+interface StakeholderManagementProps {
+ organisationId: string
+}
+
+export function StakeholderManagement({ organisationId }: StakeholderManagementProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false)
+ const [editingStakeholder, setEditingStakeholder] = useState(null)
+ const [expandedTeams, setExpandedTeams] = useState([])
+ const [selectedTeamIds, setSelectedTeamIds] = useState([])
+ const { stakeholders, addStakeholder, updateStakeholder, removeStakeholder, loading, error } = useStakeholders()
+ const { hierarchy, loading: hierarchyLoading } = useTeamHierarchy(organisationId)
+ const { stakeholderTeams, addStakeholderTeam, removeStakeholderTeam, loading: teamsLoading } = useStakeholderTeams()
+
+ const form = useForm({
+ defaultValues: {
+ displayName: '',
+ email: '',
+ photoURL: '',
+ },
+ })
+
+ useEffect(() => {
+ if (editingStakeholder) {
+ form.reset({
+ displayName: editingStakeholder.displayName,
+ email: editingStakeholder.email,
+ photoURL: editingStakeholder.photoURL,
+ })
+ } else {
+ form.reset({
+ displayName: '',
+ email: '',
+ photoURL: '',
+ })
+ }
+ }, [editingStakeholder, form])
+
+ const onSubmit = async (data: StakeholderFormData) => {
+ try {
+ if (editingStakeholder) {
+ await updateStakeholder({
+ ...editingStakeholder,
+ ...data,
+ })
+ } else {
+ await addStakeholder({
+ ...data,
+ id: '', // The repository will generate an ID
+ })
+ }
+ setIsOpen(false)
+ setEditingStakeholder(null)
+ form.reset()
+ } catch (error) {
+ console.error('Error managing stakeholder:', error)
+ }
+ }
+
+ const handleDelete = async (stakeholder: Stakeholder) => {
+ if (confirm('Are you sure you want to delete this stakeholder?')) {
+ try {
+ await removeStakeholder(stakeholder.id)
+ } catch (error) {
+ console.error('Error deleting stakeholder:', error)
+ }
+ }
+ }
+
+ // Get all teams a stakeholder belongs to
+ const getTeamsForStakeholder = (stakeholderId: string): string[] => {
+ return stakeholderTeams
+ .filter(st => st.stakeholderId === stakeholderId)
+ .map(st => st.teamId)
+ }
+
+ // Handle adding a stakeholder to teams
+ const handleUpdateStakeholderTeams = async () => {
+ if (!editingStakeholder) return
+
+ try {
+ // Get current team assignments
+ const currentTeamIds = getTeamsForStakeholder(editingStakeholder.id)
+
+ // Teams to add
+ const teamsToAdd = selectedTeamIds.filter(teamId => !currentTeamIds.includes(teamId))
+
+ // Teams to remove
+ const teamsToRemove = currentTeamIds.filter(teamId => !selectedTeamIds.includes(teamId))
+
+ // Add stakeholder to new teams
+ for (const teamId of teamsToAdd) {
+ await addStakeholderTeam({
+ stakeholderId: editingStakeholder.id,
+ teamId,
+ organisationId
+ })
+ }
+
+ // Remove stakeholder from teams they should no longer be in
+ for (const teamId of teamsToRemove) {
+ await removeStakeholderTeam(editingStakeholder.id, teamId)
+ }
+
+ setIsTeamDialogOpen(false)
+ } catch (err) {
+ console.error('Error updating team assignments:', err)
+ }
+ }
+
+ // Open dialog to edit stakeholder team assignments
+ const openTeamDialog = (stakeholder: Stakeholder) => {
+ setEditingStakeholder(stakeholder)
+ setSelectedTeamIds(getTeamsForStakeholder(stakeholder.id))
+ setIsTeamDialogOpen(true)
+ }
+
+ // Toggle team selection in the dialog
+ const toggleTeamSelection = (teamId: string) => {
+ setSelectedTeamIds(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ )
+ }
+
+ const toggleTeam = (teamId: string) => {
+ setExpandedTeams(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ )
+ }
+
+ // Render team hierarchy in the dialog
+ const renderTeamHierarchy = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const isExpanded = expandedTeams.includes(teamId)
+ const hasChildren = Object.keys(team.children).length > 0
+
+ return (
+
+
+
toggleTeam(teamId)}
+ >
+ {hasChildren ? (
+ isExpanded ? :
+ ) : (
+
+ )}
+
+
+
toggleTeamSelection(teamId)}
+ className="mr-2"
+ />
+
+
+ {team.name}
+
+
+
+ {isExpanded && (
+
+ {Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamHierarchy(childId, childTeam, level + 1)
+ )}
+
+ )}
+
+ )
+ }
+
+ if (loading || hierarchyLoading || teamsLoading) {
+ return Loading stakeholders...
+ }
+
+ if (error) {
+ return (
+
+ Error loading stakeholders: {error.message}
+
+ )
+ }
+
+ return (
+
+
+
+
+ setEditingStakeholder(null)}>
+
+ Add Stakeholder
+
+
+
+
+ {editingStakeholder ? 'Edit' : 'Add'} Stakeholder
+
+ {editingStakeholder
+ ? 'Edit the stakeholder details below'
+ : 'Add a new stakeholder to your organization'}
+
+
+
+
+
+
+
+
+
+
+
+
+ Stakeholder
+ Teams
+ Actions
+
+
+
+ {stakeholders.map((stakeholder) => {
+ const stakeholderTeamIds = getTeamsForStakeholder(stakeholder.id)
+ const stakeholderTeamNames = stakeholderTeamIds
+ .map(teamId => hierarchy?.teams[teamId]?.name || 'Unknown Team')
+ .join(', ')
+
+ return (
+
+
+
+
+
+
+ {stakeholder.displayName
+ ? stakeholder.displayName
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase()
+ : "👤"}
+
+
+
+
{stakeholder.displayName}
+
{stakeholder.email}
+
+
+
+
+ {stakeholderTeamNames || No teams assigned }
+
+
+
+
{
+ setEditingStakeholder(stakeholder)
+ setIsOpen(true)
+ }}
+ >
+
+
+
handleDelete(stakeholder)}
+ >
+
+
+
openTeamDialog(stakeholder)}
+ >
+ Manage Teams
+
+
+
+
+ )
+ })}
+ {stakeholders.length === 0 && (
+
+
+ No stakeholders found. Add your first stakeholder to get started.
+
+
+ )}
+
+
+
+
+ {/* Dialog for managing stakeholder team assignments */}
+
+
+
+ Manage Teams for {editingStakeholder?.displayName}
+
+ Select the teams this stakeholder should be a member of
+
+
+
+ {hierarchy && Object.entries(hierarchy.teams).map(([teamId, team]) => {
+ if (!team.parentId) {
+ return renderTeamHierarchy(teamId, team, 0)
+ }
+ return null
+ })}
+
+
+
+ Update Team Assignments
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/StakeholderTeamManagement.tsx b/components/StakeholderTeamManagement.tsx
new file mode 100644
index 0000000..7ae4687
--- /dev/null
+++ b/components/StakeholderTeamManagement.tsx
@@ -0,0 +1,292 @@
+'use client'
+
+import { useState } from 'react'
+import { useTeamHierarchy } from '@/hooks/useTeamHierarchy'
+import { useStakeholders } from '@/hooks/useStakeholders'
+import { useStakeholderTeams } from '@/hooks/useStakeholderTeams'
+import { Stakeholder } from '@/lib/domain/Stakeholder'
+import { TeamHierarchyNode } from '@/lib/domain/TeamHierarchy'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Checkbox } from '@/components/ui/checkbox'
+import { ChevronDown, ChevronRight, Search } from 'lucide-react'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { Label } from '@/components/ui/label'
+
+interface StakeholderTeamManagementProps {
+ organisationId: string
+}
+
+export function StakeholderTeamManagement({ organisationId }: StakeholderTeamManagementProps) {
+ const { hierarchy, loading: hierarchyLoading } = useTeamHierarchy(organisationId)
+ const { stakeholders, loading: stakeholdersLoading } = useStakeholders()
+ const { stakeholderTeams, addStakeholderTeam, removeStakeholderTeam, loading: teamsLoading } = useStakeholderTeams()
+
+ const [expandedTeams, setExpandedTeams] = useState([])
+ const [searchQuery, setSearchQuery] = useState('')
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
+ const [selectedStakeholder, setSelectedStakeholder] = useState(null)
+ const [selectedTeamIds, setSelectedTeamIds] = useState([])
+
+ // Filter stakeholders based on search query
+ const filteredStakeholders = stakeholders.filter(stakeholder =>
+ stakeholder.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ stakeholder.email.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+
+ const loading = hierarchyLoading || stakeholdersLoading || teamsLoading
+
+ if (loading) {
+ return Loading...
+ }
+
+ if (!hierarchy) {
+ return No team hierarchy found
+ }
+
+ const toggleTeam = (teamId: string) => {
+ setExpandedTeams(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ )
+ }
+
+ // Get all teams a stakeholder belongs to
+ const getTeamsForStakeholder = (stakeholderId: string): string[] => {
+ return stakeholderTeams
+ .filter(st => st.stakeholderId === stakeholderId)
+ .map(st => st.teamId)
+ }
+
+ // Handle adding a stakeholder to teams
+ const handleAddStakeholderToTeams = async () => {
+ if (!selectedStakeholder) return
+
+ try {
+ // Get current team assignments
+ const currentTeamIds = getTeamsForStakeholder(selectedStakeholder.id)
+
+ // Teams to add
+ const teamsToAdd = selectedTeamIds.filter(teamId => !currentTeamIds.includes(teamId))
+
+ // Teams to remove
+ const teamsToRemove = currentTeamIds.filter(teamId => !selectedTeamIds.includes(teamId))
+
+ // Add stakeholder to new teams
+ for (const teamId of teamsToAdd) {
+ await addStakeholderTeam({
+ stakeholderId: selectedStakeholder.id,
+ teamId,
+ organisationId
+ })
+ }
+
+ // Remove stakeholder from teams they should no longer be in
+ for (const teamId of teamsToRemove) {
+ await removeStakeholderTeam(selectedStakeholder.id, teamId)
+ }
+
+ // Reset state
+ setSelectedStakeholder(null)
+ setSelectedTeamIds([])
+ setIsAddDialogOpen(false)
+ } catch (err) {
+ console.error('Error updating stakeholder teams:', err)
+ alert(`Failed to update stakeholder teams: ${err instanceof Error ? err.message : String(err)}`)
+ }
+ }
+
+ // Open dialog to edit stakeholder team assignments
+ const openEditDialog = (stakeholder: Stakeholder) => {
+ setSelectedStakeholder(stakeholder)
+ setSelectedTeamIds(getTeamsForStakeholder(stakeholder.id))
+ setIsAddDialogOpen(true)
+ }
+
+ // Toggle team selection in the dialog
+ const toggleTeamSelection = (teamId: string) => {
+ setSelectedTeamIds(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ )
+ }
+
+ // Render team hierarchy in the dialog
+ const renderTeamHierarchy = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const isExpanded = expandedTeams.includes(teamId)
+ const hasChildren = Object.keys(team.children).length > 0
+
+ return (
+
+
+
toggleTeam(teamId)}
+ >
+ {hasChildren ? (
+ isExpanded ? :
+ ) : (
+
+ )}
+
+
+
toggleTeamSelection(teamId)}
+ className="mr-2"
+ />
+
+
+ {team.name}
+
+
+
+ {isExpanded && (
+
+ {Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamHierarchy(childId, childTeam, level + 1)
+ )}
+
+ )}
+
+ )
+ }
+
+ // Render stakeholder row with team information
+ const renderStakeholderRow = (stakeholder: Stakeholder) => {
+ const stakeholderTeamIds = getTeamsForStakeholder(stakeholder.id)
+ const stakeholderTeamNames = stakeholderTeamIds
+ .map(teamId => hierarchy.teams[teamId]?.name || 'Unknown Team')
+ .join(', ')
+
+ return (
+
+
+
+
+
+ {stakeholder.displayName[0]}
+
+
+
{stakeholder.displayName}
+
{stakeholder.email}
+
+
+
+
+ {stakeholderTeamNames || No teams assigned }
+
+
+ openEditDialog(stakeholder)}
+ >
+ Manage Teams
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+
+
+
+
+ Stakeholder
+ Teams
+ Actions
+
+
+
+ {filteredStakeholders.length === 0 ? (
+
+
+ No stakeholders found
+
+
+ ) : (
+ filteredStakeholders.map(renderStakeholderRow)
+ )}
+
+
+
+ {/* Dialog for managing stakeholder team assignments */}
+
+
+
+ Manage Team Assignments
+
+ Select teams for this stakeholder
+
+ {selectedStakeholder && (
+
+
+
+ {selectedStakeholder.displayName[0]}
+
+
+
{selectedStakeholder.displayName}
+
{selectedStakeholder.email}
+
+
+ )}
+
+
+
+
Select teams for this stakeholder:
+
+ {Object.entries(hierarchy.teams)
+ .filter(([, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamHierarchy(teamId, team, 0))}
+
+
+
+
+ setIsAddDialogOpen(false)}>
+ Cancel
+
+
+ Save Assignments
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/TeamHierarchyManagement.tsx b/components/TeamHierarchyManagement.tsx
new file mode 100644
index 0000000..be1e644
--- /dev/null
+++ b/components/TeamHierarchyManagement.tsx
@@ -0,0 +1,126 @@
+import { useState } from 'react'
+import { useTeamHierarchy } from '@/hooks/useTeamHierarchy'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { ChevronDown, ChevronRight, Plus, Trash } from 'lucide-react'
+import { TeamHierarchyNode } from '@/lib/domain/TeamHierarchy'
+
+interface TeamHierarchyManagementProps {
+ organisationId: string
+}
+
+export function TeamHierarchyManagement({ organisationId }: TeamHierarchyManagementProps) {
+ const { hierarchy, loading, error, addTeam, removeTeam, updateTeam } = useTeamHierarchy(organisationId)
+ const [expandedTeams, setExpandedTeams] = useState([])
+ const [newTeamName, setNewTeamName] = useState('')
+ const [selectedParentId, setSelectedParentId] = useState(null)
+
+ if (loading) return Loading team hierarchy...
+ if (error) return Error: {error.message}
+
+ const toggleTeam = (teamId: string) => {
+ setExpandedTeams(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ )
+ }
+
+ const handleAddTeam = async () => {
+ if (!newTeamName.trim()) return
+
+ try {
+ await addTeam({
+ id: `team-${Date.now()}`,
+ name: newTeamName,
+ parentId: selectedParentId,
+ children: {}
+ })
+ setNewTeamName('')
+ setSelectedParentId(null)
+ } catch (err) {
+ console.error('Failed to add team:', err)
+ }
+ }
+
+ const handleRemoveTeam = async (teamId: string) => {
+ if (!confirm('Are you sure you want to remove this team and all its children?')) return
+
+ try {
+ await removeTeam(teamId)
+ } catch (err) {
+ console.error('Failed to remove team:', err)
+ }
+ }
+
+ const renderTeamNode = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const isExpanded = expandedTeams.includes(teamId)
+ const hasChildren = Object.keys(team.children || {}).length > 0
+
+ return (
+
+
+
toggleTeam(teamId)}
+ className="p-1 hover:bg-accent rounded-sm"
+ disabled={!hasChildren}
+ >
+ {hasChildren && (
+ isExpanded ? :
+ )}
+
+
+
updateTeam(teamId, { ...team, name: e.target.value })}
+ className="h-8 w-48"
+ />
+
+
setSelectedParentId(teamId)}
+ className="h-8 w-8"
+ >
+
+
+
+
handleRemoveTeam(teamId)}
+ className="h-8 w-8 text-destructive"
+ >
+
+
+
+
+ {isExpanded && Object.entries(team.children || {}).map(([childId, childTeam]) =>
+ renderTeamNode(childId, childTeam, level + 1)
+ )}
+
+ )
+ }
+
+ return (
+
+
+ setNewTeamName(e.target.value)}
+ className="w-48"
+ />
+
+ Add {selectedParentId ? 'Child' : 'Root'} Team
+
+
+
+
+ {hierarchy && Object.entries(hierarchy.teams)
+ .filter(([, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamNode(teamId, team, 0))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/TeamHierarchyTree.tsx b/components/TeamHierarchyTree.tsx
new file mode 100644
index 0000000..4dcd77e
--- /dev/null
+++ b/components/TeamHierarchyTree.tsx
@@ -0,0 +1,364 @@
+import React, { useState } from 'react'
+import { ChevronDown, ChevronRight, Plus, Edit, Trash, MoveVertical } from 'lucide-react'
+import { useTeamHierarchy } from '@/hooks/useTeamHierarchy'
+import { TeamHierarchyNode } from '@/lib/domain/TeamHierarchy'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+
+interface TeamHierarchyTreeProps {
+ organisationId: string
+}
+
+// Special value to represent "no parent" (root team)
+const ROOT_TEAM_VALUE = "root_team_special_value"
+
+export function TeamHierarchyTree({ organisationId }: TeamHierarchyTreeProps) {
+ const { hierarchy, loading, error, addTeam, updateTeam, moveTeam, removeTeam } = useTeamHierarchy(organisationId)
+ const [expandedTeams, setExpandedTeams] = useState([])
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
+ const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false)
+ const [newTeamName, setNewTeamName] = useState('')
+ const [newTeamParentId, setNewTeamParentId] = useState(null)
+ const [selectedTeam, setSelectedTeam] = useState | null>(null)
+ const [moveToParentId, setMoveToParentId] = useState(null)
+
+ // Helper function to convert from UI select value to actual parentId
+ const getParentId = (selectValue: string): string | null => {
+ return selectValue === ROOT_TEAM_VALUE ? null : selectValue
+ }
+
+ if (loading) {
+ return Loading team hierarchy...
+ }
+
+ if (error) {
+ return Error: {error.message}
+ }
+
+ if (!hierarchy) {
+ return No team hierarchy found
+ }
+
+ const toggleTeam = (teamId: string) => {
+ setExpandedTeams(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ )
+ }
+
+ const handleAddTeam = async () => {
+ if (!newTeamName.trim()) return
+
+ try {
+ await addTeam({
+ id: `team-${Date.now()}`,
+ name: newTeamName.trim(),
+ parentId: newTeamParentId
+ })
+ setNewTeamName('')
+ setNewTeamParentId(null)
+ setIsAddDialogOpen(false)
+ } catch (err) {
+ console.error('Error adding team:', err)
+ alert(`Failed to add team: ${err instanceof Error ? err.message : String(err)}`)
+ }
+ }
+
+ const handleEditTeam = async () => {
+ if (!selectedTeam || !selectedTeam.name.trim()) return
+
+ try {
+ await updateTeam(selectedTeam)
+ setSelectedTeam(null)
+ setIsEditDialogOpen(false)
+ } catch (err) {
+ console.error('Error updating team:', err)
+ alert(`Failed to update team: ${err instanceof Error ? err.message : String(err)}`)
+ }
+ }
+
+ const handleMoveTeam = async () => {
+ if (!selectedTeam) return
+
+ try {
+ await moveTeam(selectedTeam.id, moveToParentId)
+ setSelectedTeam(null)
+ setMoveToParentId(null)
+ setIsMoveDialogOpen(false)
+ } catch (err) {
+ console.error('Error moving team:', err)
+ alert(`Failed to move team: ${err instanceof Error ? err.message : String(err)}`)
+ }
+ }
+
+ const handleRemoveTeam = async (teamId: string) => {
+ if (!confirm('Are you sure you want to remove this team?')) return
+
+ try {
+ await removeTeam(teamId)
+ } catch (err) {
+ console.error('Error removing team:', err)
+ alert(`Failed to remove team: ${err instanceof Error ? err.message : String(err)}`)
+ }
+ }
+
+ const openEditDialog = (team: TeamHierarchyNode) => {
+ setSelectedTeam({
+ id: team.id,
+ name: team.name,
+ parentId: team.parentId
+ })
+ setIsEditDialogOpen(true)
+ }
+
+ const openMoveDialog = (team: TeamHierarchyNode) => {
+ setSelectedTeam({
+ id: team.id,
+ name: team.name,
+ parentId: team.parentId
+ })
+ setMoveToParentId(team.parentId)
+ setIsMoveDialogOpen(true)
+ }
+
+ // Get all teams as a flat array for select options
+ const getAllTeams = (): TeamHierarchyNode[] => {
+ return Object.values(hierarchy.teams)
+ }
+
+ // Render a single team node
+ const renderTeamNode = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const isExpanded = expandedTeams.includes(teamId)
+ const hasChildren = Object.keys(team.children).length > 0
+
+ return (
+
+
+
toggleTeam(teamId)}
+ >
+ {hasChildren ? (
+ isExpanded ? :
+ ) : (
+
+ )}
+
+
+
{team.name}
+
+
+ openEditDialog(team)}
+ title="Edit team"
+ >
+
+
+
+ openMoveDialog(team)}
+ title="Move team"
+ >
+
+
+
+ handleRemoveTeam(team.id)}
+ title="Remove team"
+ disabled={hasChildren}
+ >
+
+
+
+
+
+ {isExpanded && (
+
+ {Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamNode(childId, childTeam, level + 1)
+ )}
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+
Teams
+
setIsAddDialogOpen(true)}>
+
+ Add Team
+
+
+
+
+ {Object.keys(hierarchy.teams).length === 0 ? (
+
+ No teams yet. Click "Add Team" to create your first team.
+
+ ) : (
+
+ {Object.entries(hierarchy.teams)
+ .filter(([, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamNode(teamId, team, 0))}
+
+ )}
+
+
+ {/* Add Team Dialog */}
+
+
+
+ Add New Team
+
+ Create a new team in your organization hierarchy.
+
+
+
+
+
+ Team Name
+ setNewTeamName(e.target.value)}
+ placeholder="Enter team name"
+ />
+
+
+
+ Parent Team (optional)
+ setNewTeamParentId(getParentId(value))}
+ >
+
+
+
+
+ No Parent (Root Team)
+ {getAllTeams().map((team) => (
+
+ {team.name}
+
+ ))}
+
+
+
+
+
+
+ setIsAddDialogOpen(false)}>
+ Cancel
+
+
+ Add Team
+
+
+
+
+
+ {/* Edit Team Dialog */}
+
+
+
+ Edit Team
+
+ Update the team's information.
+
+
+
+ {selectedTeam && (
+
+
+ Team Name
+ setSelectedTeam({ ...selectedTeam, name: e.target.value })}
+ placeholder="Enter team name"
+ />
+
+
+ )}
+
+
+ setIsEditDialogOpen(false)}>
+ Cancel
+
+
+ Save Changes
+
+
+
+
+
+ {/* Move Team Dialog */}
+
+
+
+ Move Team
+
+ Change the team's position in the hierarchy.
+
+
+
+ {selectedTeam && (
+
+
+ Moving: {selectedTeam.name}
+
+
+
+ New Parent Team
+ setMoveToParentId(getParentId(value))}
+ >
+
+
+
+
+ No Parent (Root Team)
+ {getAllTeams()
+ .filter(team => team.id !== selectedTeam.id)
+ .map((team) => (
+
+ {team.name}
+
+ ))}
+
+
+
+
+ )}
+
+
+ setIsMoveDialogOpen(false)}>
+ Cancel
+
+
+ Move Team
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/add-decision-relationship-dialog.tsx b/components/add-decision-relationship-dialog.tsx
index 79abed0..37be29f 100644
--- a/components/add-decision-relationship-dialog.tsx
+++ b/components/add-decision-relationship-dialog.tsx
@@ -24,30 +24,25 @@ import {
} from '@/components/ui/popover'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
-import { useProjectDecisions } from '@/hooks/useProjectDecisions'
+import { useOrganisationDecisions } from '@/hooks/useOrganisationDecisions'
import { Decision } from '@/lib/domain/Decision'
-
-interface SelectedDecisionDetails {
- toDecisionId: string
- toTeamId: string
- toProjectId: string
- organisationId: string
-}
+import { SelectedDecisionDetails } from '@/hooks/useDecisionRelationships'
interface AddDecisionRelationshipDialogProps {
onAdd: (details: SelectedDecisionDetails) => Promise
relationshipDescription: string
children?: React.ReactNode
+ organisationId: string
}
-export function AddDecisionRelationshipDialog({ onAdd, relationshipDescription, children }: AddDecisionRelationshipDialogProps) {
+export function AddDecisionRelationshipDialog({ onAdd, relationshipDescription, children, organisationId }: AddDecisionRelationshipDialogProps) {
const [open, setOpen] = useState(false)
const [selectedDecisionId, setSelectedDecisionId] = useState('')
const [errors, setErrors] = useState([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [comboboxOpen, setComboboxOpen] = useState(false)
- const { decisions } = useProjectDecisions()
+ const { decisions } = useOrganisationDecisions(organisationId)
const resetForm = () => {
setSelectedDecisionId('')
@@ -72,12 +67,12 @@ export function AddDecisionRelationshipDialog({ onAdd, relationshipDescription,
return
}
- await onAdd({
+ const selectedDecisionDetails: SelectedDecisionDetails = {
toDecisionId: selectedDecision.id,
- toTeamId: selectedDecision.teamId,
- toProjectId: selectedDecision.projectId,
organisationId: selectedDecision.organisationId,
- })
+ }
+
+ await onAdd(selectedDecisionDetails);
resetForm()
setOpen(false)
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index c8ee3c0..1d62890 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -2,12 +2,13 @@
import * as React from "react"
import {
- FolderKanban,
- Users2,
+ Sparkles,
+ ListTodo,
+ Users,
} from "lucide-react"
-import { usePathname } from 'next/navigation'
+import { useParams } from 'next/navigation'
+import Link from 'next/link'
-import { NavMain } from "./nav-main"
import { NavUser } from "./nav-user"
import { OrganisationSwitcher, useOrganisation } from "@/components/organisation-switcher"
import {
@@ -16,13 +17,22 @@ import {
SidebarFooter,
SidebarHeader,
SidebarRail,
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuItem,
+ SidebarMenuButton,
+ useSidebar,
} from "@/components/ui/sidebar"
import { useAuth } from "@/hooks/useAuth"
export function AppSidebar(props: React.ComponentProps) {
- const { user } = useAuth();
+ const { user, isAdmin } = useAuth();
const { selectedOrganisation } = useOrganisation();
- const pathname = usePathname();
+ const params = useParams();
+ const organisationId = params.organisationId as string;
+ const { state } = useSidebar();
+ const isCollapsed = state === "collapsed";
// Create user data object from authenticated user
const userData = user ? {
@@ -33,32 +43,57 @@ export function AppSidebar(props: React.ComponentProps) {
if (!userData) return null;
- // Extract current team ID from URL
- const teamMatch = pathname?.match(/\/team\/([^/]+)/);
- const currentTeamId = teamMatch ? teamMatch[1] : null;
-
- // Create navigation data from selected team's projects
- const navData = {
- navMain: selectedOrganisation?.teams.map(team => ({
- title: team.name,
- url: `/organisation/${selectedOrganisation.id}/team/${team.id}`,
- icon: Users2,
- isActive: team.id === currentTeamId,
- items: team.projects.map(project => ({
- title: project.name,
- url: `/organisation/${selectedOrganisation.id}/team/${team.id}/project/${project.id}`,
- icon: FolderKanban,
- })),
- })) || [],
- };
-
return (
-
+ {selectedOrganisation && (
+ <>
+ {/* Organisation section */}
+
+ {selectedOrganisation.name}
+
+
+
+
+
+ {!isCollapsed && Decision list }
+
+
+
+
+
+
+
+
+
+ {!isCollapsed && New decision }
+
+
+
+
+
+
+ {/* Admin section - only visible to admins */}
+ {isAdmin && (
+
+ Admin
+
+
+
+
+
+ {!isCollapsed && Teams }
+
+
+
+
+
+ )}
+ >
+ )}
diff --git a/components/decision-relationships-list.tsx b/components/decision-relationships-list.tsx
index bfbfb4d..d01eb6b 100644
--- a/components/decision-relationships-list.tsx
+++ b/components/decision-relationships-list.tsx
@@ -1,130 +1,126 @@
+import { Decision, DecisionRelationshipType } from '@/lib/domain/Decision'
import { Button } from '@/components/ui/button'
-import { DecisionRelationship, DecisionRelationshipType } from '@/lib/domain/DecisionRelationship'
-import { X, Plus } from 'lucide-react'
import { AddDecisionRelationshipDialog } from '@/components/add-decision-relationship-dialog'
import { useDecisionRelationships, SelectedDecisionDetails } from '@/hooks/useDecisionRelationships'
-import { Decision } from '@/lib/domain/Decision'
-import { useProjectDecisions } from '@/hooks/useProjectDecisions'
+import { Plus, X } from 'lucide-react'
+import Link from 'next/link'
interface DecisionRelationshipItemProps {
- relationship: DecisionRelationship
- onRemove: (relationship: DecisionRelationship) => Promise
- relatedDecisionTitle: string
+ targetDecision: Decision;
+ type: DecisionRelationshipType;
+ onRemove: (type: DecisionRelationshipType, targetDecision: Decision) => Promise;
}
-function DecisionRelationshipItem({ relationship, onRemove, relatedDecisionTitle }: DecisionRelationshipItemProps) {
+function DecisionRelationshipItem({ targetDecision, type, onRemove }: DecisionRelationshipItemProps) {
return (
-
-
{relatedDecisionTitle}
+
+
+ {targetDecision.title}
+
onRemove(relationship)}
+ onClick={() => onRemove(type, targetDecision)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove relationship"
>
- )
+ );
}
interface DecisionRelationshipsListProps {
- relationshipType: DecisionRelationshipType
- fromDecision: Decision
+ fromDecision: Decision;
+ relationshipType: DecisionRelationshipType;
+ title: string;
}
-export function DecisionRelationshipsList({
- relationshipType,
- fromDecision
+export function DecisionRelationshipsList({
+ fromDecision,
+ relationshipType,
+ title,
}: DecisionRelationshipsListProps) {
const { addRelationship, removeRelationship } = useDecisionRelationships(fromDecision);
- const { decisions } = useProjectDecisions();
-
- const getDecisionTitle = (decisionId: string) => {
- return decisions?.find(d => d.id === decisionId)?.title || 'Unknown Decision'
- }
-
- // Get relationships based on type
- const getRelationshipsForType = (type: DecisionRelationshipType): DecisionRelationship[] => {
- switch (type) {
- case 'supersedes':
- return fromDecision.supersedes;
- case 'blocked_by':
- return fromDecision.blockedBy;
- default:
- return [];
- }
- };
- // Filter relationships to only show the specified type
- const filteredRelationships = getRelationshipsForType(relationshipType);
-
- const handleAdd = async (selectedDecisionDetails: SelectedDecisionDetails) => {
- await addRelationship(selectedDecisionDetails, relationshipType);
+ const getRelationshipsForType = (type: DecisionRelationshipType) => {
+ const relationships = fromDecision.getRelationshipsByType(type);
+ return relationships.map(relationship => ({
+ targetDecision: Decision.create({
+ id: relationship.targetDecision.id,
+ title: relationship.targetDecisionTitle,
+ description: '',
+ cost: 'low',
+ createdAt: new Date(),
+ reversibility: 'hat',
+ stakeholders: [],
+ driverStakeholderId: '',
+ organisationId: fromDecision.organisationId,
+ teamIds: [],
+ projectIds: [],
+ supportingMaterials: []
+ }),
+ type: relationship.type
+ }));
};
- const handleRemove = async (relationship: DecisionRelationship) => {
- await removeRelationship(relationship);
+ const handleAdd = async (details: SelectedDecisionDetails) => {
+ await addRelationship(details, relationshipType);
};
- const getRelationshipDescription = (type: DecisionRelationshipType): string => {
- switch (type) {
- case 'supersedes':
- return 'supersedes';
- case 'blocked_by':
- return 'blocked by';
- default:
- return type;
- }
+ const handleRemove = async (type: DecisionRelationshipType, targetDecision: Decision) => {
+ await removeRelationship(type, targetDecision);
};
const getRelationshipDescriptionForAddDialog = (type: DecisionRelationshipType): string => {
switch (type) {
- case 'supersedes':
- return 'superseded';
case 'blocked_by':
- return 'blocking';
- default:
- return type;
+ return 'Select a decision that blocks this decision';
+ case 'blocks':
+ return 'Select a decision that this decision blocks';
+ case 'supersedes':
+ return 'Select a decision that this decision supersedes';
+ case 'superseded_by':
+ return 'Select a decision that supersedes this decision';
}
};
+ const relationships = getRelationshipsForType(relationshipType);
+ const hasRelationships = relationships.length > 0;
+
return (
-
-
-
{getRelationshipDescription(relationshipType)} decision (s)
-
+
- {filteredRelationships.map((relationship) => (
-
- ))}
- {filteredRelationships.length === 0 && (
-
- None
-
+ {hasRelationships ? (
+ relationships.map((relationship) => (
+
+ ))
+ ) : (
+
None
)}
- )
-}
\ No newline at end of file
+ );
+}
\ No newline at end of file
diff --git a/components/decision-summary.tsx b/components/decision-summary.tsx
new file mode 100644
index 0000000..9d730b7
--- /dev/null
+++ b/components/decision-summary.tsx
@@ -0,0 +1,123 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"
+import { Decision } from "@/lib/domain/Decision"
+import { Stakeholder } from "@/lib/domain/Stakeholder"
+import { StakeholderRoleGroups } from "@/components/stakeholders/StakeholderRoleGroups"
+import { TipTapView } from '@/components/tiptap-view'
+
+interface DecisionSummaryProps {
+ decision: Decision
+ stakeholders?: Stakeholder[]
+ compact?: boolean
+}
+
+export function DecisionSummary({
+ decision,
+ stakeholders,
+ compact = false,
+}: DecisionSummaryProps) {
+ const supersedesRelationship = decision.getRelationshipsByType('supersedes')[0];
+ const supersededByRelationship = decision.getRelationshipsByType('superseded_by')[0];
+ return (
+
+
+ Decision: {decision.title}
+ {supersedesRelationship && (
+
+ supersedes {supersedesRelationship.targetDecisionTitle}
+
+ )}
+ {supersededByRelationship && (
+
+ superseded by {supersededByRelationship.targetDecisionTitle}
+
+ )}
+
+
+
+
+ {!compact && (
+
+
+
Cost
+
{decision.cost}
+
+
+
Reversibility
+
{decision.reversibility}
+
+
+ )}
+
+
+
+ {!compact && decision.supportingMaterials && decision.supportingMaterials.length > 0 && (
+
+
Supporting Materials
+
+
+ )}
+
+
+
Method
+
+ {decision.decisionMethod?.replace('_', ' ') || "No method selected"}
+
+
+
+ {stakeholders && (
+
+
Stakeholders
+
+
+ )}
+
+ {decision.decisionNotes && !compact && (
+
+ )}
+
+ {!compact && (
+
+
+
Created
+
+ {decision.createdAt.toLocaleDateString()}
+
+
+
+
Published
+
+ {decision.publishDate ? decision.publishDate.toLocaleDateString() : 'Not published'}
+
+
+
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/editor.tsx b/components/editor.tsx
deleted file mode 100644
index b6c378a..0000000
--- a/components/editor.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Bold, Italic, Heading2, Quote, List, ListOrdered, Link2, Image, Eye, Columns, Maximize2, HelpCircle } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Card } from '@/components/ui/card'
-import { Separator } from '@/components/ui/separator'
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from '@/components/ui/tooltip'
-import { Textarea } from '@/components/ui/textarea'
-
-const tools = [
- { icon: Bold, tooltip: 'Bold' },
- { icon: Italic, tooltip: 'Italic' },
- { icon: Heading2, tooltip: 'Heading' },
- { icon: Quote, tooltip: 'Quote' },
- { icon: List, tooltip: 'Bullet list' },
- { icon: ListOrdered, tooltip: 'Numbered list' },
- { icon: Link2, tooltip: 'Link' },
- { icon: Image, tooltip: 'Image' },
- { icon: Eye, tooltip: 'Preview' },
- { icon: Columns, tooltip: 'Side by side' },
- { icon: Maximize2, tooltip: 'Fullscreen' },
- { icon: HelpCircle, tooltip: 'Help' },
-]
-
-interface EditorProps {
- content: string
- onChange: (content: string) => void
- className?: string
-}
-
-export function Editor({ content, onChange, className = '' }: EditorProps) {
- return (
-
-
-
- {tools.map((Tool, index) => (
-
- {index === 6 && }
- {index === 8 && }
-
-
-
-
-
-
- {Tool.tooltip}
-
-
- ))}
-
-
-
- )
-}
-
diff --git a/components/in-progress-table.tsx b/components/in-progress-table.tsx
index 11a68a9..7cc5a95 100644
--- a/components/in-progress-table.tsx
+++ b/components/in-progress-table.tsx
@@ -28,7 +28,6 @@ import {
import { WorkflowProgress } from "@/components/ui/workflow-progress"
import { cn } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
-import { DecisionWorkflowSteps } from "@/lib/domain/Decision"
const projects = [
{ value: "all", label: "All Projects" },
@@ -179,7 +178,7 @@ export function InProgressTable() {
-
+
{formatDistanceToNow(new Date(decision.lastActivity), { addSuffix: true })}
diff --git a/components/nav-main.tsx b/components/nav-main.tsx
deleted file mode 100644
index 2c53cad..0000000
--- a/components/nav-main.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-"use client"
-
-import { ChevronRight, LucideIcon } from 'lucide-react'
-
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
-import {
- SidebarGroup,
- SidebarGroupLabel,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
- SidebarMenuSub,
- SidebarMenuSubButton,
- SidebarMenuSubItem,
-} from "@/components/ui/sidebar"
-
-export function NavMain({
- items,
-}: {
- items: {
- title: string
- url: string
- icon?: LucideIcon
- isActive?: boolean
- items?: {
- title: string
- url: string
- }[]
- }[]
-}) {
- return (
-
- Teams
-
- {items.map((item) => (
-
-
-
-
- {item.icon && }
-
- {item.title}
-
-
-
-
-
-
- {item.items?.map((subItem) => (
-
-
-
- {subItem.title}
-
-
-
- ))}
-
-
-
-
- ))}
-
-
- )
-}
-
diff --git a/components/nav-projects.tsx b/components/nav-projects.tsx
deleted file mode 100644
index e6e3ac2..0000000
--- a/components/nav-projects.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-"use client"
-
-import {
- Folder,
- Forward,
- MoreHorizontal,
- Trash2,
- type LucideIcon,
-} from "lucide-react"
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import {
- SidebarGroup,
- SidebarGroupLabel,
- SidebarMenu,
- SidebarMenuAction,
- SidebarMenuButton,
- SidebarMenuItem,
- useSidebar,
-} from "@/components/ui/sidebar"
-
-export function NavProjects({
- projects,
-}: {
- projects: {
- name: string
- url: string
- icon: LucideIcon
- }[]
-}) {
- const { isMobile } = useSidebar()
-
- return (
-
- Projects
-
- {projects.map((item) => (
-
-
-
-
- {item.name}
-
-
-
-
-
-
- More
-
-
-
-
-
- View Project
-
-
-
- Share Project
-
-
-
-
- Delete Project
-
-
-
-
- ))}
-
-
-
- More
-
-
-
-
- )
-}
-
diff --git a/components/nav-user.tsx b/components/nav-user.tsx
index eff7ab6..f1b58c7 100644
--- a/components/nav-user.tsx
+++ b/components/nav-user.tsx
@@ -38,8 +38,9 @@ export function NavUser({
avatar: string
}
}) {
- const { isMobile } = useSidebar()
+ const { isMobile, state } = useSidebar()
const [isOpen, setIsOpen] = useState(false)
+ const isCollapsed = state === "collapsed"
const handleLogout = async () => {
try {
@@ -58,21 +59,34 @@ export function NavUser({
- CN
+
+ {user.name.substring(0, 2).toUpperCase()}
+
-
- {user.name}
- {user.email}
-
-
+ {!isCollapsed && (
+ <>
+
+ {user.name}
+ {user.email}
+
+
+ >
+ )}
- CN
+
+ {user.name.substring(0, 2).toUpperCase()}
+
{user.name}
diff --git a/components/org-section.tsx b/components/org-section.tsx
deleted file mode 100644
index 435ec10..0000000
--- a/components/org-section.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-"use client";
-
-import { useState } from "react";
-
-import { Organisation } from "@/lib/domain/Organisation";
-import { Team } from "@/lib/domain/Team";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import {
- Plus,
- Check,
- X,
- Trash2,
- ChevronRight,
- ChevronDown,
-} from "lucide-react";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { cn } from "@/lib/utils";
-
-interface OrgSectionProps {
- organisations: Organisation[];
- setOrganisations: (orgs: Organisation[]) => void;
- handleAddTeamToOrg: (orgId: string) => void;
-}
-
-interface TreeItem {
- id: string;
- name: string;
- type: "org" | "team";
- parentId?: string;
- isExpanded?: boolean;
- level: number;
-}
-
-export function OrgSection({
- organisations,
- setOrganisations,
- handleAddTeamToOrg,
-}: OrgSectionProps) {
- const [editingId, setEditingId] = useState
(null);
- const [editingName, setEditingName] = useState("");
- const [expandedOrgs, setExpandedOrgs] = useState>(new Set());
-
- // Convert organisations and teams into a flat tree structure
- const treeItems: TreeItem[] = [
- ...organisations.map((org) => ({
- id: org.id,
- name: org.name,
- type: "org" as const,
- level: 0,
- isExpanded: expandedOrgs.has(org.id),
- })),
- ...organisations.flatMap((org) =>
- org.teams.map((team) => ({
- id: team.id,
- name: team.name,
- type: "team" as const,
- parentId: org.id,
- level: 1,
- })),
- ),
- ];
-
- // Sort items so teams appear under their organisations
- const sortedItems = treeItems.sort((a, b) => {
- // First, compare the parent IDs or org IDs
- const aParentId = a.parentId || a.id;
- const bParentId = b.parentId || b.id;
-
- if (aParentId !== bParentId) {
- return aParentId.localeCompare(bParentId);
- }
-
- // If they have the same parent (or are both orgs), sort by type then name
- if (a.type !== b.type) {
- return a.type === "org" ? -1 : 1;
- }
-
- return a.name.localeCompare(b.name);
- });
-
- const handleSave = (id: string, type: "org" | "team") => {
- if (type === 'org') {
- setOrganisations(
- organisations.map((org) =>
- org.id === id ? Organisation.create({ ...org, name: editingName }) : org
- )
- );
- } else {
- setOrganisations(
- organisations.map((org) =>
- Organisation.create({
- ...org,
- teams: org.teams.map((team) =>
- team.id === id ? Team.create({ ...team, name: editingName }) : team
- )
- })
- )
- );
- }
- setEditingId(null);
- };
-
- const handleCancel = () => {
- setEditingId(null);
- };
-
- const handleDelete = (id: string, type: "org" | "team") => {
- if (type === 'org') {
- setOrganisations(organisations.filter((org) => org.id !== id));
- } else {
- setOrganisations(
- organisations.map((org) =>
- Organisation.create({
- ...org,
- teams: org.teams.filter((team) => team.id !== id)
- })
- )
- );
- }
- };
-
- const toggleExpand = (id: string) => {
- const newExpanded = new Set(expandedOrgs);
- if (newExpanded.has(id)) {
- newExpanded.delete(id);
- } else {
- newExpanded.add(id);
- }
- setExpandedOrgs(newExpanded);
- };
-
- // Filter items to show based on expanded state
- const visibleItems = sortedItems.filter((item) => {
- if (item.type === "org") return true;
- return item.parentId && expandedOrgs.has(item.parentId);
- });
-
- return (
-
-
-
- Name
- Actions
-
-
-
- {visibleItems.map((item) => (
-
-
-
-
- {item.type === "org" && (
-
toggleExpand(item.id)}
- >
- {item.isExpanded ? (
-
- ) : (
-
- )}
-
- )}
- {item.type === "team" &&
}
- {editingId === item.id ? (
-
setEditingName(e.target.value)}
- placeholder={`${item.type === "org" ? "Organisation" : "Team"} name`}
- autoFocus
- />
- ) : (
-
{
- setEditingId(item.id);
- setEditingName(item.name);
- }}
- >
- {item.name || "(Click to edit)"}
-
- )}
-
-
-
-
- {editingId === item.id ? (
- <>
-
handleSave(item.id, item.type)}
- >
-
-
-
handleCancel()}
- >
-
-
- >
- ) : (
- <>
- {item.type === "org" && (
-
handleAddTeamToOrg(item.id)}
- >
-
-
- )}
-
handleDelete(item.id, item.type)}
- >
-
-
- >
- )}
-
-
-
- ))}
-
-
- );
-}
diff --git a/components/organisation-switcher.tsx b/components/organisation-switcher.tsx
index 137d068..3a0139c 100644
--- a/components/organisation-switcher.tsx
+++ b/components/organisation-switcher.tsx
@@ -46,8 +46,13 @@ export function OrganisationProvider({ children }: { children: React.ReactNode }
const handleOrganisationSelect = React.useCallback((org: Organisation) => {
setSelectedOrganisation(org)
- router.push(`/organisation/${org.id}`)
- }, [router])
+
+ // Don't redirect if we're on the admin page
+ const isAdminPage = pathname?.startsWith('/admin')
+ if (!isAdminPage) {
+ router.push(`/organisation/${org.id}`)
+ }
+ }, [router, pathname])
const value = React.useMemo(() => ({
selectedOrganisation,
diff --git a/components/recent-decisions-table.tsx b/components/recent-decisions-table.tsx
deleted file mode 100644
index 2c1b623..0000000
--- a/components/recent-decisions-table.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-
-const recentDecisions = [
- {
- id: "1",
- title: "Frontend Framework Selection",
- team: "Engineering",
- decider: {
- name: "Sarah Chen",
- avatar: "/avatars/sarah.jpg",
- initials: "SC",
- },
- status: "In Progress",
- lastActivity: "2h ago",
- },
- {
- id: "2",
- title: "Q1 Marketing Strategy",
- team: "Marketing",
- decider: {
- name: "Michael Park",
- avatar: "/avatars/michael.jpg",
- initials: "MP",
- },
- status: "Decided",
- lastActivity: "5h ago",
- },
- {
- id: "3",
- title: "New Product Pricing",
- team: "Product",
- decider: {
- name: "Emma Wilson",
- avatar: "/avatars/emma.jpg",
- initials: "EW",
- },
- status: "In Progress",
- lastActivity: "1d ago",
- },
- {
- id: "4",
- title: "Design System Update",
- team: "Design",
- decider: {
- name: "James Lee",
- avatar: "/avatars/james.jpg",
- initials: "JL",
- },
- status: "Decided",
- lastActivity: "1d ago",
- },
- {
- id: "5",
- title: "API Architecture",
- team: "Engineering",
- decider: {
- name: "Alex Kumar",
- avatar: "/avatars/alex.jpg",
- initials: "AK",
- },
- status: "In Progress",
- lastActivity: "2d ago",
- },
-]
-
-export function RecentDecisionsTable() {
- return (
-
-
-
- Decision
- Team
- Decider
- Status
- Last Activity
-
-
-
- {recentDecisions.map((decision) => (
-
- {decision.title}
- {decision.team}
-
-
-
-
- {decision.decider.initials}
-
- {decision.decider.name}
-
-
-
-
- {decision.status}
-
-
- {decision.lastActivity}
-
- ))}
-
-
- )
-}
-
diff --git a/components/role-assignment.tsx b/components/role-assignment.tsx
index 223de53..e8dea67 100644
--- a/components/role-assignment.tsx
+++ b/components/role-assignment.tsx
@@ -16,7 +16,7 @@ interface RoleAssignmentProps {
export function RoleAssignment({ decision }: RoleAssignmentProps) {
const { getStakeholdersForDecision, loading: stakeholdersLoading } = useStakeholders()
- const { updateStakeholders } = useDecision(decision.id)
+ const { updateStakeholders } = useDecision(decision.id, decision.organisationId)
const [stakeholdersWithRoles, setStakeholdersWithRoles] = useState([])
const [loading, setLoading] = useState(true)
diff --git a/components/stakeholder-section.tsx b/components/stakeholder-section.tsx
index a6f378d..e8f626d 100644
--- a/components/stakeholder-section.tsx
+++ b/components/stakeholder-section.tsx
@@ -132,7 +132,7 @@ export function StakeholderSection({
.split(" ")
.map((n) => n[0])
.join("")
- : "?"}
+ : "👤"}
diff --git a/components/stakeholders/StakeholderListView.tsx b/components/stakeholders/StakeholderListView.tsx
new file mode 100644
index 0000000..852291b
--- /dev/null
+++ b/components/stakeholders/StakeholderListView.tsx
@@ -0,0 +1,139 @@
+import { useState } from "react";
+import { Search } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { useStakeholders } from "@/hooks/useStakeholders";
+import { useTeamHierarchy } from "@/hooks/useTeamHierarchy";
+import { useStakeholderTeams } from "@/hooks/useStakeholderTeams";
+import { motion, AnimatePresence } from "framer-motion";
+
+interface StakeholderListViewProps {
+ organisationId: string;
+ selectedStakeholderIds: string[];
+ onStakeholderChange: (stakeholderId: string | string[], selected: boolean) => void;
+ driverStakeholderId?: string;
+}
+
+export function StakeholderListView({
+ organisationId,
+ selectedStakeholderIds,
+ onStakeholderChange,
+ driverStakeholderId,
+}: StakeholderListViewProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const { stakeholders, loading: stakeholdersLoading } = useStakeholders();
+ const { hierarchy, loading: hierarchyLoading } = useTeamHierarchy(organisationId);
+ const { stakeholderTeams, loading: teamsLoading } = useStakeholderTeams();
+
+ const loading = stakeholdersLoading || hierarchyLoading || teamsLoading;
+
+ // Filter and sort stakeholders
+ const filteredAndSortedStakeholders = stakeholders
+ .filter((stakeholder) =>
+ stakeholder.displayName.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+ .sort((a, b) => {
+ // First sort by checked status
+ const aChecked = selectedStakeholderIds.includes(a.id);
+ const bChecked = selectedStakeholderIds.includes(b.id);
+ if (aChecked !== bChecked) {
+ return aChecked ? -1 : 1;
+ }
+ // Then sort by display name
+ return a.displayName.localeCompare(b.displayName);
+ });
+
+ // Get team names for a stakeholder
+ const getTeamNamesForStakeholder = (stakeholderId: string): string => {
+ if (!hierarchy) return "";
+
+ const teamIds = stakeholderTeams
+ .filter((st) => st.stakeholderId === stakeholderId)
+ .map((st) => st.teamId);
+
+ return teamIds
+ .map((teamId) => hierarchy.teams[teamId]?.name || "Unknown Team")
+ .join(", ");
+ };
+
+ if (loading) {
+ return
Loading...
;
+ }
+
+ return (
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+
+
+ {filteredAndSortedStakeholders.map((stakeholder) => (
+
+
+
+ onStakeholderChange(stakeholder.id, checked as boolean)
+ }
+ disabled={driverStakeholderId === stakeholder.id}
+ className={driverStakeholderId === stakeholder.id ?
+ "cursor-not-allowed border-gray-500 data-[state=checked]:bg-gray-500" : ""}
+ />
+
+
+
+ {stakeholder.displayName
+ ? stakeholder.displayName
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ : "👤"}
+
+
+
+
{stakeholder.displayName}
+ {driverStakeholderId === stakeholder.id && (
+
Driver
+ )}
+
+
+
+ {getTeamNamesForStakeholder(stakeholder.id) || "No teams"}
+
+
+ ))}
+
+ {filteredAndSortedStakeholders.length === 0 && (
+
+ No stakeholders found
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/stakeholders/StakeholderRoleGroups.tsx b/components/stakeholders/StakeholderRoleGroups.tsx
new file mode 100644
index 0000000..0383033
--- /dev/null
+++ b/components/stakeholders/StakeholderRoleGroups.tsx
@@ -0,0 +1,73 @@
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { Decision, StakeholderRole } from "@/lib/domain/Decision"
+import { Stakeholder } from "@/lib/domain/Stakeholder"
+
+interface StakeholderRoleGroupsProps {
+ decision: Decision
+ stakeholders: Stakeholder[]
+}
+
+function StakeholderPill({ stakeholder }: { stakeholder: Stakeholder }) {
+ return (
+
+
+
+ {stakeholder.displayName?.charAt(0) || '?'}
+
+
{stakeholder.displayName}
+
+ )
+}
+
+function StakeholderGroup({
+ title,
+ stakeholders
+}: {
+ title: string,
+ stakeholders: { stakeholder: Stakeholder, role: StakeholderRole }[]
+}) {
+ if (stakeholders.length === 0) return null;
+
+ return (
+
+
+ {title}
+
+
+ {stakeholders.map(({ stakeholder }) => (
+
+ ))}
+
+
+ )
+}
+
+export function StakeholderRoleGroups({ decision, stakeholders }: StakeholderRoleGroupsProps) {
+ const stakeholdersByRole = decision.stakeholders.reduce((acc, { stakeholder_id, role }) => {
+ const stakeholder = stakeholders.find(s => s.id === stakeholder_id);
+ if (stakeholder) {
+ acc[role] = [...(acc[role] || []), { stakeholder, role }];
+ }
+ return acc;
+ }, {} as Record
);
+
+ return (
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/stakeholders/StakeholderSelectionView.tsx b/components/stakeholders/StakeholderSelectionView.tsx
new file mode 100644
index 0000000..7a86938
--- /dev/null
+++ b/components/stakeholders/StakeholderSelectionView.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+import { TeamHierarchyTree } from "./TeamHierarchyTree";
+import { StakeholderListView } from "./StakeholderListView";
+import { Button } from "@/components/ui/button";
+import { List, Network } from "lucide-react";
+
+export interface Stakeholder {
+ id: string;
+ displayName: string;
+ teamId: string;
+ role?: string;
+}
+
+interface StakeholderSelectionViewProps {
+ organisationId: string;
+ onStakeholderChange: (stakeholderId: string | string[], selected: boolean) => void;
+ selectedStakeholderIds: string[];
+ driverStakeholderId?: string;
+}
+
+export function StakeholderSelectionView({
+ organisationId,
+ onStakeholderChange,
+ selectedStakeholderIds,
+ driverStakeholderId
+}: StakeholderSelectionViewProps) {
+ const [viewMode, setViewMode] = useState<'list' | 'hierarchy'>('hierarchy');
+
+ // Handle team selection - this is a no-op
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const handleTeamSelect = (teamId: string, checked: boolean) => {
+ // Team selection is handled by the TeamHierarchyTree component
+ // The actual selection of stakeholders happens in TeamHierarchyTree
+ };
+
+ return (
+
+
+
+ setViewMode('list')}
+ >
+
+ List View
+
+ setViewMode('hierarchy')}
+ >
+
+ Hierarchy View
+
+
+
+
+ {viewMode === 'hierarchy' ? (
+
+ ) : (
+
onStakeholderChange(id, checked)}
+ driverStakeholderId={driverStakeholderId}
+ />
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/components/stakeholders/TeamHierarchyTree.tsx b/components/stakeholders/TeamHierarchyTree.tsx
new file mode 100644
index 0000000..9ee47eb
--- /dev/null
+++ b/components/stakeholders/TeamHierarchyTree.tsx
@@ -0,0 +1,251 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { Checkbox } from "@/components/ui/checkbox";
+import { ChevronRight, Minus } from "lucide-react";
+import { useTeamHierarchy } from "@/hooks/useTeamHierarchy";
+import { useStakeholders } from "@/hooks/useStakeholders";
+import { useStakeholderTeams } from "@/hooks/useStakeholderTeams";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { TeamHierarchyNode } from "@/lib/domain/TeamHierarchy";
+import { Stakeholder } from "@/lib/domain/Stakeholder";
+
+interface ExtendedTeamHierarchyNode extends TeamHierarchyNode {
+ stakeholders: Array;
+ children: Record;
+}
+
+interface TeamHierarchyTreeProps {
+ organisationId: string;
+ onTeamSelect: (teamId: string, checked: boolean) => void;
+ onStakeholderSelect?: (stakeholderId: string | string[], checked: boolean) => void;
+ selectedStakeholderIds?: string[];
+ driverStakeholderId?: string;
+}
+
+export function TeamHierarchyTree({
+ organisationId,
+ onTeamSelect,
+ onStakeholderSelect,
+ selectedStakeholderIds = [],
+ driverStakeholderId
+}: TeamHierarchyTreeProps) {
+ const [expandedTeams, setExpandedTeams] = useState([]);
+ const { hierarchy, loading: hierarchyLoading } = useTeamHierarchy(organisationId);
+ const { stakeholders, loading: stakeholdersLoading } = useStakeholders();
+ const { stakeholderTeamsMap, loading: teamsLoading } = useStakeholderTeams();
+
+ // Helper function to get stakeholders for a team
+ const getTeamStakeholders = useCallback((teamId: string): Stakeholder[] => {
+ return stakeholders.filter(s => stakeholderTeamsMap[s.id]?.includes(teamId));
+ }, [stakeholders, stakeholderTeamsMap]);
+
+ // Helper function to convert TeamHierarchyNode to ExtendedTeamHierarchyNode
+ const extendTeamNode = useCallback((node: TeamHierarchyNode): ExtendedTeamHierarchyNode => {
+ const teamStakeholders = getTeamStakeholders(node.id);
+ return {
+ ...node,
+ stakeholders: teamStakeholders,
+ children: Object.fromEntries(
+ Object.entries(node.children || {}).map(([id, child]) => [
+ id,
+ extendTeamNode(child)
+ ])
+ )
+ };
+ }, [getTeamStakeholders]);
+
+ // Helper function to find teams containing selected stakeholders
+ const findTeamsWithSelectedStakeholders = useCallback((team: ExtendedTeamHierarchyNode): string[] => {
+ const teamsToExpand: string[] = [];
+ if (team.stakeholders.some(s => selectedStakeholderIds.includes(s.id))) {
+ teamsToExpand.push(team.id);
+ }
+ Object.values(team.children || {}).forEach(child => {
+ teamsToExpand.push(...findTeamsWithSelectedStakeholders(child));
+ });
+ return teamsToExpand;
+ }, [selectedStakeholderIds]);
+
+ // Auto-expand teams containing selected stakeholders
+ useEffect(() => {
+ if (!hierarchy) return;
+
+ const teamsToExpand: string[] = [];
+ Object.values(hierarchy.teams).forEach(team => {
+ const extendedTeam = extendTeamNode(team);
+ const teamExpansions = findTeamsWithSelectedStakeholders(extendedTeam);
+ teamsToExpand.push(...teamExpansions);
+ });
+
+ setExpandedTeams(prev => {
+ const newExpanded = Array.from(teamsToExpand);
+ // Keep any teams that were manually expanded
+ prev.forEach(teamId => newExpanded.push(teamId));
+ return Array.from(new Set(newExpanded));
+ });
+ }, [hierarchy, selectedStakeholderIds, stakeholders, stakeholderTeamsMap, extendTeamNode, findTeamsWithSelectedStakeholders]);
+
+ const toggleTeam = (teamId: string) => {
+ setExpandedTeams(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ );
+ };
+
+ const getAllStakeholderIds = (team: ExtendedTeamHierarchyNode): string[] => {
+ const stakeholderIds = team.stakeholders.map(s => s.id);
+ Object.values(team.children).forEach(childTeam => {
+ stakeholderIds.push(...getAllStakeholderIds(childTeam));
+ });
+ return stakeholderIds;
+ };
+
+ const areAllStakeholdersSelected = (team: ExtendedTeamHierarchyNode): boolean => {
+ const allStakeholderIds = getAllStakeholderIds(team);
+ return allStakeholderIds.length > 0 &&
+ allStakeholderIds.every(id => selectedStakeholderIds.includes(id));
+ };
+
+ const areSomeStakeholdersSelected = (team: ExtendedTeamHierarchyNode): boolean => {
+ const allStakeholderIds = getAllStakeholderIds(team);
+ return allStakeholderIds.some(id => selectedStakeholderIds.includes(id));
+ };
+
+ const handleTeamSelect = (teamId: string, team: ExtendedTeamHierarchyNode, checked: boolean) => {
+ // Call onTeamSelect with teamId and checked
+ onTeamSelect(teamId, checked);
+ // Handle stakeholder selection separately
+ if (onStakeholderSelect) {
+ // Get all stakeholder IDs for this team and nested teams
+ const stakeholderIds = getAllStakeholderIds(team);
+ // If there are no stakeholders to update, return early
+ if (stakeholderIds.length === 0) return;
+ // Filter to only get stakeholders that need to change state
+ // (i.e., not already in the desired state)
+ const stakeholdersToUpdate = stakeholderIds.filter(id => {
+ const isCurrentlySelected = selectedStakeholderIds.includes(id);
+ return isCurrentlySelected !== checked;
+ });
+ // If there are stakeholders to update, call onStakeholderSelect with the array
+ if (stakeholdersToUpdate.length > 0) {
+ // We manually check if each stakeholder already has the right state
+ // to avoid errors from double-selecting or double-deselecting
+ onStakeholderSelect(stakeholdersToUpdate, checked);
+ }
+ }
+ };
+
+ const renderTeamNode = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const extendedTeam = extendTeamNode(team);
+ const isExpanded = expandedTeams.includes(teamId);
+ const hasChildren = Object.keys(team.children).length > 0 || extendedTeam.stakeholders.length > 0;
+ const allSelected = areAllStakeholdersSelected(extendedTeam);
+ const someSelected = areSomeStakeholdersSelected(extendedTeam);
+ const indentStyle = { paddingLeft: `${level * 24}px` };
+
+ return (
+
+
hasChildren && toggleTeam(teamId)}
+ >
+
+ {hasChildren && (
+
+ )}
+
+
+ {
+ handleTeamSelect(teamId, extendedTeam, checked as boolean);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ className="data-[state=indeterminate]:bg-primary/20 data-[state=indeterminate]:border-primary"
+ />
+ {someSelected && !allSelected && (
+
+ )}
+
+
+ {team.name}
+ {(extendedTeam.stakeholders.length > 0 || Object.keys(team.children).length > 0) && (
+
+ ({getAllStakeholderIds(extendedTeam).length})
+
+ )}
+
+
+
+ {isExpanded && (
+
+ {extendedTeam.stakeholders.map((stakeholder) => (
+
+
+
+ onStakeholderSelect?.(stakeholder.id, checked as boolean)
+ }
+ disabled={driverStakeholderId === stakeholder.id}
+ className={driverStakeholderId === stakeholder.id ?
+ "cursor-not-allowed border-gray-500 data-[state=checked]:bg-gray-500" : ""}
+ />
+
+
+
+ {stakeholder.displayName
+ ? stakeholder.displayName
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ : "👤"}
+
+
+
+ {stakeholder.displayName}
+ {driverStakeholderId === stakeholder.id && (
+ Driver
+ )}
+
+
+ ))}
+
+ {Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamNode(childId, childTeam, level + 1)
+ )}
+
+ )}
+
+ );
+ };
+
+ if (hierarchyLoading || stakeholdersLoading || teamsLoading) {
+ return Loading...
;
+ }
+
+ if (!hierarchy) {
+ return No team hierarchy found
;
+ }
+
+ return (
+
+ {Object.entries(hierarchy.teams)
+ .filter(([, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamNode(teamId, team, 0))}
+
+ );
+}
\ No newline at end of file
diff --git a/components/supporting-materials-list.tsx b/components/supporting-materials-list.tsx
index bd00340..8018814 100644
--- a/components/supporting-materials-list.tsx
+++ b/components/supporting-materials-list.tsx
@@ -7,9 +7,10 @@ import { SupportingMaterialIcon } from '@/components/supporting-material-icon'
interface SupportingMaterialItemProps {
material: SupportingMaterial
onRemove: (url: string) => Promise
+ readOnly?: boolean
}
-function SupportingMaterialItem({ material, onRemove }: SupportingMaterialItemProps) {
+function SupportingMaterialItem({ material, onRemove, readOnly = false }: SupportingMaterialItemProps) {
return (
@@ -24,15 +25,17 @@ function SupportingMaterialItem({ material, onRemove }: SupportingMaterialItemPr
{material.title}
-
onRemove(material.url)}
- className="opacity-0 group-hover:opacity-100 transition-opacity"
- title="Remove material"
- >
-
-
+ {!readOnly && (
+
onRemove(material.url)}
+ className="opacity-0 group-hover:opacity-100 transition-opacity"
+ title="Remove material"
+ >
+
+
+ )}
)
}
@@ -41,23 +44,26 @@ interface SupportingMaterialsListProps {
materials: SupportingMaterial[]
onAdd: (material: SupportingMaterial) => Promise
onRemove: (url: string) => Promise
+ readOnly?: boolean
}
-export function SupportingMaterialsList({ materials, onAdd, onRemove }: SupportingMaterialsListProps) {
+export function SupportingMaterialsList({ materials, onAdd, onRemove, readOnly = false }: SupportingMaterialsListProps) {
return (
Supporting Materials
-
-
-
-
-
+ {!readOnly && (
+
+
+
+
+
+ )}
{materials.map((material, index) => (
@@ -65,6 +71,7 @@ export function SupportingMaterialsList({ materials, onAdd, onRemove }: Supporti
key={`${material.url}-${index}`}
material={material}
onRemove={onRemove}
+ readOnly={readOnly}
/>
))}
{materials.length === 0 && (
diff --git a/components/tiptap-editor.tsx b/components/tiptap-editor.tsx
new file mode 100644
index 0000000..300a8d5
--- /dev/null
+++ b/components/tiptap-editor.tsx
@@ -0,0 +1,243 @@
+'use client'
+
+import * as React from 'react'
+import { useEditor, EditorContent } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { Markdown } from 'tiptap-markdown'
+import { Bold, Italic, List, ListOrdered, Code, Heading1, Heading2, Heading3 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Card } from '@/components/ui/card'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { cn } from "@/lib/utils"
+
+interface TipTapEditorProps {
+ content: string
+ onChange: (content: string) => void
+ className?: string
+ minimal?: boolean
+}
+
+const getEditorClassNames = (minimal: boolean) => cn(
+ 'prose prose-sm dark:prose-invert focus:outline-none max-w-none',
+ minimal ? 'p-2' : 'p-4 min-h-[200px]'
+);
+
+export function TipTapEditor({ content, onChange, className = '', minimal = false }: TipTapEditorProps) {
+ const [isFocused, setIsFocused] = React.useState(false);
+ const [isRawMode, setIsRawMode] = React.useState(false);
+ const [rawMarkdown, setRawMarkdown] = React.useState(content || '');
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ bold: {
+ HTMLAttributes: {
+ class: 'font-bold'
+ }
+ },
+ heading: {
+ levels: [1, 2, 3]
+ },
+ hardBreak: {
+ keepMarks: true,
+ HTMLAttributes: {
+ class: 'my-2'
+ }
+ },
+ paragraph: {
+ HTMLAttributes: {
+ class: 'mb-2'
+ }
+ }
+ }),
+ Markdown.configure({
+ html: false,
+ transformPastedText: true,
+ transformCopiedText: true,
+ breaks: true
+ })
+ ],
+ content: rawMarkdown,
+ onUpdate: ({ editor }) => {
+ const markdown = editor.storage.markdown.getMarkdown();
+ console.log('markdown content:', markdown);
+
+ setRawMarkdown(markdown);
+ onChange(markdown);
+ },
+ editorProps: {
+ attributes: {
+ class: getEditorClassNames(minimal)
+ }
+ },
+ onFocus: () => setIsFocused(true),
+ onBlur: () => setIsFocused(false)
+ });
+
+ // Update raw markdown when content changes from outside
+ React.useEffect(() => {
+ if (!isRawMode) {
+ setRawMarkdown(content || '');
+ }
+ }, [content, isRawMode]);
+
+ // Global event handler for keyboard shortcuts
+ React.useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (isFocused && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
+ // Prevent default behavior AND stop propagation
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Manually toggle bold in the editor
+ if (editor) {
+ editor.chain().focus().toggleBold().run();
+ }
+
+ return false;
+ }
+ };
+
+ // Add to document level to catch all events
+ document.addEventListener('keydown', handleKeyDown, true);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown, true);
+ };
+ }, [isFocused, editor]);
+
+ // Toggle between rich text and raw markdown modes
+ const toggleEditMode = () => {
+ if (isRawMode && editor) {
+ editor.commands.clearContent();
+ editor.commands.setContent(rawMarkdown);
+ }
+ setIsRawMode(!isRawMode);
+ };
+
+ // Handle raw markdown changes
+ const handleRawMarkdownChange = (e: React.ChangeEvent
) => {
+ const newValue = e.target.value;
+ setRawMarkdown(newValue);
+ onChange(newValue);
+ };
+
+ if (!editor && !isRawMode) {
+ return null;
+ }
+
+ const tools = [
+ {
+ icon: Heading1,
+ title: 'Heading 1',
+ action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
+ isActive: () => editor?.isActive('heading', { level: 1 }) || false,
+ showInRawMode: false,
+ },
+ {
+ icon: Heading2,
+ title: 'Heading 2',
+ action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
+ isActive: () => editor?.isActive('heading', { level: 2 }) || false,
+ showInRawMode: false,
+ },
+ {
+ icon: Heading3,
+ title: 'Heading 3',
+ action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run(),
+ isActive: () => editor?.isActive('heading', { level: 3 }) || false,
+ showInRawMode: false,
+ },
+ {
+ icon: Bold,
+ title: 'Bold',
+ action: () => editor?.chain().focus().toggleBold().run(),
+ isActive: () => editor?.isActive('bold') || false,
+ showInRawMode: false,
+ },
+ {
+ icon: Italic,
+ title: 'Italic',
+ action: () => editor?.chain().focus().toggleItalic().run(),
+ isActive: () => editor?.isActive('italic') || false,
+ showInRawMode: false,
+ },
+ {
+ icon: List,
+ title: 'Bullet List',
+ action: () => editor?.chain().focus().toggleBulletList().run(),
+ isActive: () => editor?.isActive('bulletList') || false,
+ showInRawMode: false,
+ },
+ {
+ icon: ListOrdered,
+ title: 'Numbered List',
+ action: () => editor?.chain().focus().toggleOrderedList().run(),
+ isActive: () => editor?.isActive('orderedList') || false,
+ showInRawMode: false,
+ },
+ {
+ icon: Code,
+ title: 'Toggle Raw Markdown',
+ action: toggleEditMode,
+ isActive: () => isRawMode,
+ showInRawMode: true,
+ },
+ ];
+
+ return (
+
+ {!minimal && (
+
+
+ {tools
+ .filter(tool => isRawMode ? tool.showInRawMode : true)
+ .map((Tool) => (
+
+
+
+
+
+
+ {Tool.title}
+
+ ))}
+
+
+ )}
+
+ {isRawMode ? (
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/components/tiptap-view.tsx b/components/tiptap-view.tsx
new file mode 100644
index 0000000..30d2171
--- /dev/null
+++ b/components/tiptap-view.tsx
@@ -0,0 +1,81 @@
+'use client'
+
+import * as React from 'react'
+import { useEditor, EditorContent } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { Markdown } from 'tiptap-markdown'
+
+interface TipTapViewProps {
+ content: string
+ className?: string
+}
+
+export function TipTapView({ content, className = '' }: TipTapViewProps) {
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ bold: {
+ HTMLAttributes: {
+ class: 'font-bold'
+ }
+ },
+ heading: {
+ levels: [1, 2, 3]
+ },
+ hardBreak: {
+ keepMarks: true,
+ HTMLAttributes: {
+ class: 'my-2'
+ }
+ },
+ paragraph: {
+ HTMLAttributes: {
+ class: 'mb-2'
+ }
+ }
+ }),
+ Markdown.configure({
+ html: false,
+ transformPastedText: true,
+ transformCopiedText: true,
+ breaks: true
+ })
+ ],
+ content: typeof content === 'string' ? (content.startsWith('"') ? JSON.parse(content) : content) : '',
+ editable: false,
+ editorProps: {
+ attributes: {
+ class: 'prose prose-sm dark:prose-invert focus:outline-none max-w-none p-4'
+ }
+ }
+ });
+
+ // Update editor content when content prop changes
+ React.useEffect(() => {
+ if (editor && content !== undefined) {
+ try {
+ const parsedContent = content.startsWith('"') ? JSON.parse(content) : content;
+ // Only update if content actually changed
+ if (editor.storage.markdown.getMarkdown() !== parsedContent) {
+ editor.commands.setContent(parsedContent);
+ }
+ } catch (e) {
+ console.error('Error parsing content:', e);
+ editor.commands.setContent('');
+ }
+ }
+ }, [editor, content]);
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..2f55a32
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/form.tsx b/components/ui/form.tsx
new file mode 100644
index 0000000..ad1ae02
--- /dev/null
+++ b/components/ui/form.tsx
@@ -0,0 +1,178 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..f957126
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
\ No newline at end of file
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx
index eeb2d7a..51d768f 100644
--- a/components/ui/sidebar.tsx
+++ b/components/ui/sidebar.tsx
@@ -21,7 +21,7 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
-const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH = "10rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..d6bb57f
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
\ No newline at end of file
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
new file mode 100644
index 0000000..db59cba
--- /dev/null
+++ b/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background text-foreground",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
\ No newline at end of file
diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx
new file mode 100644
index 0000000..4f75b51
--- /dev/null
+++ b/components/ui/toaster.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast"
+import { useToast } from "@/components/ui/use-toast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, icon, ...props }) {
+ return (
+
+
+ {icon && {icon} }
+ {title && {title} }
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/ui/use-toast.tsx b/components/ui/use-toast.tsx
new file mode 100644
index 0000000..d787e05
--- /dev/null
+++ b/components/ui/use-toast.tsx
@@ -0,0 +1,194 @@
+"use client"
+
+// Inspired by react-hot-toast library
+import * as React from "react"
+
+import type {
+ ToastActionElement,
+ ToastProps,
+} from "@/components/ui/toast"
+
+const TOAST_LIMIT = 5
+const TOAST_REMOVE_DELAY = 5000 // 5 seconds instead of 1000000
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+ icon?: React.ReactNode
+}
+
+// Use const assertions for action type values
+const ACTION_TYPE = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_VALUE
+ return count.toString()
+}
+
+type Action =
+ | {
+ type: typeof ACTION_TYPE.ADD_TOAST
+ toast: ToasterToast
+ }
+ | {
+ type: typeof ACTION_TYPE.UPDATE_TOAST
+ toast: Partial
+ }
+ | {
+ type: typeof ACTION_TYPE.DISMISS_TOAST
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: typeof ACTION_TYPE.REMOVE_TOAST
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({
+ type: ACTION_TYPE.REMOVE_TOAST,
+ toastId: toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case ACTION_TYPE.ADD_TOAST:
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case ACTION_TYPE.UPDATE_TOAST:
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case ACTION_TYPE.DISMISS_TOAST: {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
+ }
+ case ACTION_TYPE.REMOVE_TOAST:
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+type Toast = Omit
+
+function toast({ ...props }: Toast) {
+ const id = genId()
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: ACTION_TYPE.UPDATE_TOAST,
+ toast: { ...props, id },
+ })
+ const dismiss = () => dispatch({ type: ACTION_TYPE.DISMISS_TOAST, toastId: id })
+
+ dispatch({
+ type: ACTION_TYPE.ADD_TOAST,
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ }
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState)
+
+ React.useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
+ }
+ }, [state])
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: ACTION_TYPE.DISMISS_TOAST, toastId }),
+ }
+}
+
+export { useToast, toast }
\ No newline at end of file
diff --git a/components/ui/workflow-progress.tsx b/components/ui/workflow-progress.tsx
index 5ace2d6..ffea1d9 100644
--- a/components/ui/workflow-progress.tsx
+++ b/components/ui/workflow-progress.tsx
@@ -1,14 +1,14 @@
import * as React from "react"
import { cn } from "@/lib/utils"
-import { DecisionWorkflowStep, DecisionWorkflowSteps } from "@/lib/domain/Decision"
+import { DecisionWorkflowStepsSequence } from "@/lib/domain/Decision"
-export function WorkflowProgress({ currentStep }: { currentStep: DecisionWorkflowStep }) {
+export function WorkflowProgress({ currentStep }: { currentStep: number }) {
return (
- {DecisionWorkflowSteps.map((step, index) => {
+ {DecisionWorkflowStepsSequence.map((step, index) => {
const StepIcon = step.icon
- const isCompleted = DecisionWorkflowSteps.indexOf(currentStep) > index
- const isActive = step === currentStep
+ const isCompleted = currentStep > index + 1
+ const isActive = index + 1 === currentStep
return (
void
+ className?: string
+ organisationId?: string
+ decisionId?: string
+}
+
+export default function WorkflowAccordion({
+ currentStep = DecisionWorkflowSteps.IDENTIFY,
+ onStepChange,
+ className,
+ organisationId = "9HY1YTkOdqxOTFOMZe8r",
+ decisionId = "KRWdpmQTU2DRR76jrlC4"
+}: WorkflowAccordionProps) {
+ const currentStepIndex = WorkflowNavigator.getStepIndex(currentStep)
+ const [openSteps, setOpenSteps] = useState
([currentStep.key])
+ const [driverOpen, setDriverOpen] = useState(false)
+ const [selectedMethod, setSelectedMethod] = useState(null)
+ const { toast } = useToast()
+ const router = useRouter()
+
+ const {
+ decision,
+ loading: decisionsLoading,
+ updateDecisionTitle,
+ updateDecisionDescription,
+ updateDecisionCost,
+ updateDecisionReversibility,
+ updateDecisionDriver,
+ updateDecisionMethod,
+ updateDecisionContent,
+ addSupportingMaterial,
+ removeSupportingMaterial,
+ addStakeholder,
+ removeStakeholder,
+ updateStakeholders,
+ publishDecision,
+ updateDecisionNotes,
+ } = useDecision(decisionId, organisationId)
+
+ const {
+ stakeholders,
+ loading: stakeholdersLoading,
+ } = useStakeholders()
+ const { loading: stakeholderTeamsLoading } = useStakeholderTeams()
+ const { loading: organisationsLoading } = useOrganisations()
+
+ // Update the open steps when currentStep changes
+ useEffect(() => {
+ setOpenSteps(prev => {
+ if (!prev.includes(currentStep.key)) {
+ return [...prev, currentStep.key]
+ }
+ return prev
+ })
+ }, [currentStep])
+
+ // Update selected method when decision changes
+ useEffect(() => {
+ if (decision?.decisionMethod) {
+ setSelectedMethod(decision.decisionMethod as DecisionMethod)
+ }
+ }, [decision])
+
+ const handleStakeholderChange = (stakeholderId: string | string[], checked: boolean) => {
+ if (!decision) return;
+
+ // Handle array of stakeholder IDs for team selection
+ if (Array.isArray(stakeholderId) && stakeholderId.length > 0) {
+ try {
+ // Create an updated list of stakeholders
+ let updatedStakeholders = [...decision.stakeholders];
+ if (checked) {
+ // Add new stakeholders that aren't already in the list
+ const existingIds = new Set(updatedStakeholders.map(s => s.stakeholder_id));
+ stakeholderId.forEach(id => {
+ if (!existingIds.has(id)) {
+ updatedStakeholders.push({
+ stakeholder_id: id,
+ role: "informed"
+ });
+ }
+ });
+ } else {
+ // Remove stakeholders that are in the list to remove,
+ // but preserve the driver stakeholder
+ const idsToRemove = new Set(stakeholderId);
+ // If driver is in the removal list, show a toast notification
+ if (decision.driverStakeholderId && idsToRemove.has(decision.driverStakeholderId)) {
+ // Remove driver from the list to prevent the error
+ idsToRemove.delete(decision.driverStakeholderId);
+ }
+ updatedStakeholders = updatedStakeholders.filter(
+ s => !idsToRemove.has(s.stakeholder_id)
+ );
+ }
+ // Update all stakeholders in one batch operation
+ updateStakeholders(updatedStakeholders);
+ return;
+ } catch (error) {
+ console.error("Error updating stakeholders:", error);
+ return;
+ }
+ }
+
+ // Handle single stakeholder ID
+ try {
+ // At this point, stakeholderId must be a string
+ const id = stakeholderId as string;
+ // Check if trying to remove the driver
+ if (!checked && id === decision.driverStakeholderId) {
+ return;
+ }
+ if (checked) {
+ if (!decision.decisionStakeholderIds.includes(id)) {
+ addStakeholder(id);
+ }
+ } else {
+ if (decision.decisionStakeholderIds.includes(id)) {
+ removeStakeholder(id);
+ }
+ }
+ } catch (error) {
+ console.error(`Error processing stakeholder ${stakeholderId}:`, error);
+ }
+ };
+
+ const handleMethodSelect = (method: DecisionMethod) => {
+ setSelectedMethod(method)
+ updateDecisionMethod(method)
+ }
+
+ const handleStepComplete = useCallback((step: DecisionWorkflowStep) => {
+ const nextStep = WorkflowNavigator.getNextStep(step);
+ if (nextStep && onStepChange) {
+ onStepChange(nextStep);
+ }
+ }, [onStepChange]);
+
+ const renderStepContent = (step: DecisionWorkflowStep) => {
+ if (step.key === 'identify' && decision && stakeholders) {
+ const uniqueOrgStakeholders = stakeholders.sort((a, b) => a.displayName.localeCompare(b.displayName));
+
+ return (
+
+
+
+ Driver
+
+
+
+
+
+ {decision.driverStakeholderId ? (
+ (() => {
+ const driverStakeholder = uniqueOrgStakeholders.find(
+ (s) => s.id === decision.driverStakeholderId,
+ );
+ return (
+ <>
+
+
+
+
+ {driverStakeholder?.displayName
+ ? driverStakeholder.displayName
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ : "👤"}
+
+
+ {driverStakeholder?.displayName}
+
+
+ >
+ );
+ })()
+ ) : (
+ <>
+ Select driver...
+
+ >
+ )}
+
+
+
+
+
+ No stakeholder found.
+
+ {uniqueOrgStakeholders.map((stakeholder) => (
+ {
+ updateDecisionDriver(stakeholder.id);
+ setDriverOpen(false);
+ }}
+ >
+
+
+
+ {stakeholder.displayName
+ ? stakeholder.displayName
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ : "👤"}
+
+
+ {stakeholder.displayName}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Title
+
+ updateDecisionTitle(e.target.value)}
+ className="flex-1"
+ />
+
+
+
+
+
+
+
+ Details
+ updateDecisionDescription(content)}
+ />
+
+
+
+
+ Cost
+
+ - how much will it cost (in effort, time or money) to implement?
+
+
+
+ updateDecisionCost(value as Cost)
+ }
+ >
+
+
+ Low
+
+
+
+ Medium
+
+
+
+ High
+
+
+
+
+
+
+
+ Reversibility
+
+
+ - like choosing a
+
+
+
+ updateDecisionReversibility(value as Reversibility)
+ }
+ >
+
+
+ Hat
+
+
+
+ Haircut
+
+
+
+ Tattoo
+
+
+
+
+ )
+ }
+
+ if (step.key === 'stakeholders' && decision && stakeholders) {
+ return (
+
+
+
+
+ Stakeholders
+
+
+ - who has an interest in - or is impacted by - this decision?
+
+
+
+
+
+ )
+ }
+
+ if (step.key === 'method' && decision) {
+ return (
+
+
+
+
+
+ Decision making method
+
+ Given the assigned roles assigned; one of the following methods could be used:
+
+
+
+
+ handleMethodSelect("accountable_individual")}
+ />
+ handleMethodSelect("consent")}
+ />
+
+
+
+
+
+ )
+ }
+
+ if (step.key === 'choose' && decision) {
+ return (
+
+
+
Notes
+ updateDecisionNotes(content)}
+ />
+
+
+
+
+
+
+
+
+
Decision
+
+ - state your decision concisely in 1-2 sentences
+
+
+
+ updateDecisionContent(content)}
+ className="prose-sm min-h-[4rem] max-h-[8rem] overflow-y-auto"
+ minimal
+ />
+
+
+
+ )
+ }
+
+ if (step.key === 'publish' && decision) {
+ return (
+
+
+
+ Publish Decision
+
+ - review and publish your decision to inform all stakeholders
+
+
+
+
+
+
{
+ try {
+ await publishDecision();
+ toast({
+ title: "Decision Published",
+ description: "The decision has been successfully published and stakeholders will be notified .",
+ });
+ handleStepComplete(step);
+ // Only redirect on successful publish
+ router.push(`/organisation/${decision.organisationId}`);
+ } catch (error) {
+ toast({
+ variant: "destructive",
+ title: "Failed to Publish Decision",
+ description: error instanceof Error ? error.message : "An unexpected error occurred",
+ });
+ // Don't redirect or complete step on error
+ }
+ }}
+ >
+ Publish
+
+
+
+ )
+ }
+
+ return (
+
+
{step.label} Content
+
+ This is placeholder content for the {step.label.toLowerCase()} step.
+ In the actual implementation, this will contain the specific form or content for this step.
+
+
+ )
+ }
+
+ if (decisionsLoading || stakeholdersLoading || stakeholderTeamsLoading || organisationsLoading) {
+ return Loading...
+ }
+
+ return (
+
+ {DecisionWorkflowStepsSequence.map((step, index) => {
+ const isCompleted = index < currentStepIndex
+ const isCurrent = step.key === currentStep.key
+ const isDisabled = index > currentStepIndex
+
+ return (
+
+
+
+
+
+ {renderStepContent(step)}
+ {isCurrent && step.key !== 'publish' && (
+
+
{
+ handleStepComplete(step);
+ }}
+ stepLabel={step.label}
+ />
+
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
\ No newline at end of file
diff --git a/components/workflow/WorkflowAccordionComponents.tsx b/components/workflow/WorkflowAccordionComponents.tsx
new file mode 100644
index 0000000..5f5b8d7
--- /dev/null
+++ b/components/workflow/WorkflowAccordionComponents.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import { cn } from '@/lib/utils'
+import { ArrowDown } from 'lucide-react'
+import { DecisionWorkflowStep, DecisionWorkflowStepKey, StepRoles } from '@/lib/domain/Decision'
+import { STEP_DESCRIPTIONS, STYLE_CLASSES, StepKey } from './WorkflowAccordionConstants'
+
+interface StepHeaderProps {
+ step: DecisionWorkflowStep;
+ isCurrent: boolean;
+}
+
+export function StepHeader({ step, isCurrent }: StepHeaderProps) {
+ return (
+
+
+
+
+ {step.label}
+
+
+
+
+
+
+
+ );
+}
+
+interface RolePillProps {
+ role: 'Driver' | 'Decider';
+}
+
+export function RolePill({ role }: RolePillProps) {
+ return (
+
+ {role}
+
+ );
+}
+
+interface StepDescriptionProps {
+ stepKey: StepKey;
+}
+
+export function StepDescription({ stepKey }: StepDescriptionProps) {
+ return (
+
+ {STEP_DESCRIPTIONS[stepKey]}
+
+ );
+}
+
+interface ProgressBarProps {
+ currentStepIndex: number;
+ totalSteps: number;
+}
+
+export function ProgressBar({ currentStepIndex, totalSteps }: ProgressBarProps) {
+ return (
+
+ );
+}
+
+interface NextButtonProps {
+ onComplete: () => void;
+ stepLabel: string;
+}
+
+export function NextButton({ onComplete, stepLabel }: NextButtonProps) {
+ if (typeof onComplete !== 'function') {
+ return null;
+ }
+
+ return (
+ {
+ onComplete();
+ }}
+ className="mr-4 px-4 py-2 bg-primary text-primary-foreground rounded-md inline-flex items-center gap-2 hover:bg-primary/90 transition-colors hover:scale-105 active:scale-95"
+ aria-label={`Complete ${stepLabel} and proceed to next step`}
+ >
+ Next
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/workflow/WorkflowAccordionConstants.ts b/components/workflow/WorkflowAccordionConstants.ts
new file mode 100644
index 0000000..c5d93fb
--- /dev/null
+++ b/components/workflow/WorkflowAccordionConstants.ts
@@ -0,0 +1,22 @@
+export type StepKey = 'identify' | 'stakeholders' | 'method' | 'choose' | 'publish';
+
+export const STEP_DESCRIPTIONS: Record = {
+ identify: "identifies what the decision is about",
+ stakeholders: "selects which stakeholders should be involved in / aware of the decision",
+ method: "assigns roles and pick a decision making method",
+ choose: "makes a choice",
+ publish: "informs stakeholders of the decision"
+} as const;
+
+export const STYLE_CLASSES = {
+ accordionItem: {
+ base: "border rounded-lg transition-all duration-200",
+ disabled: "opacity-50",
+ current: "ring-2 ring-primary ring-offset-2",
+ completed: "bg-emerald-50/50"
+ },
+ stepIcon: {
+ base: "h-6 w-6",
+ current: "text-primary"
+ }
+} as const;
\ No newline at end of file
diff --git a/components/workflow/horizontal-workflow-progress.tsx b/components/workflow/horizontal-workflow-progress.tsx
new file mode 100644
index 0000000..e4dd0af
--- /dev/null
+++ b/components/workflow/horizontal-workflow-progress.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import React from "react";
+import { Card } from "@/components/ui/card";
+import { ChevronRight } from "lucide-react";
+import {
+ DecisionWorkflowSteps,
+ DecisionWorkflowStep,
+ DecisionWorkflowStepKey,
+ DecisionWorkflowStepsSequence,
+ WorkflowNavigator,
+ StepRoles,
+} from "@/lib/domain/Decision";
+import { cn } from "@/lib/utils";
+
+interface HorizontalWorkflowProgressProps {
+ currentStep?: DecisionWorkflowStep;
+ onStepChange?: (step: DecisionWorkflowStep) => void;
+ allowFutureSteps?: boolean;
+ showRoles?: boolean;
+ className?: string;
+}
+
+export default function HorizontalWorkflowProgress({
+ currentStep = DecisionWorkflowSteps.IDENTIFY,
+ onStepChange,
+ allowFutureSteps = false,
+ showRoles = true,
+ className,
+}: HorizontalWorkflowProgressProps) {
+ const currentStepIndex = WorkflowNavigator.getStepIndex(currentStep);
+
+ return (
+
+
+ {DecisionWorkflowStepsSequence.map((step, index) => {
+ const isActive = step.key === currentStep.key;
+ const isCompleted = index < currentStepIndex;
+ const isClickable =
+ onStepChange && (allowFutureSteps || isCompleted || isActive);
+ const role =
+ StepRoles[step.key.toUpperCase() as DecisionWorkflowStepKey];
+
+ return (
+
+ {/* Step with icon and label */}
+
+ {showRoles && (
+
+ {role}
+
+ )}
+
+
isClickable && onStepChange?.(step)}
+ className={cn(
+ "flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors",
+ isClickable && "cursor-pointer hover:bg-primary/90",
+ isCompleted || isActive
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted text-muted-foreground",
+ !isClickable && "cursor-not-allowed",
+ )}
+ disabled={!isClickable}
+ aria-current={isActive ? "step" : undefined}
+ >
+
+
+
+
+ {step.label}
+
+
+
+ {/* Connector line */}
+ {index < DecisionWorkflowStepsSequence.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/docs/decision_workflow.md b/docs/decision_workflow.md
new file mode 100644
index 0000000..c0417c0
--- /dev/null
+++ b/docs/decision_workflow.md
@@ -0,0 +1,157 @@
+# Decision Workflow UX Redesign
+
+## Overview
+
+The Decision Copilot application is being enhanced with a single-page workflow that replaces the current multi-page approach. This redesign addresses user feedback that indicated difficulty in referring to previously entered information while progressing through the decision-making steps.
+
+## Design Goals
+
+- Maintain the guided step-by-step workflow
+- Allow users to easily reference previous information
+- Reduce cognitive load by keeping context visible
+- Improve performance through lazy-loading techniques
+- Preserve the domain model's workflow step concept
+
+## User Experience
+
+### Current Issues
+
+- Users need to navigate between pages to reference previous information
+- Loss of context when moving between workflow steps
+- "Back" navigation disrupts the workflow
+
+### New Approach
+
+The redesigned workflow will:
+
+1. Display all steps on a single page with horizontal progress indicator
+2. Use accordions to expand/collapse each section
+3. Only load content for visible sections to maintain performance
+4. Automatically expand the current section based on decision state
+5. Allow users to manually expand previous sections for reference
+
+## UI Components
+
+### 1. Horizontal Workflow Progress
+
+A horizontal stepper showing all workflow steps:
+- Identify
+- Method
+- Options
+- Choose
+- Publish
+
+This component will:
+- Highlight the current active step
+- Show completed steps with checkmarks or filled icons
+- Connect steps with lines that change color based on completion
+- Allow clicking on steps to expand their corresponding sections
+
+### 2. Accordion Sections
+
+Each workflow step is contained in an accordion section that can expand or collapse:
+- Only one section is expanded at a time
+- Completed sections show a summary when collapsed
+- Sections are lazy-loaded when expanded
+- "Next" buttons control section progression
+
+### 3. Section Content
+
+Content within each section remains the same as the current implementation, preserving all functionality while improving the navigation experience.
+
+## Implementation Steps
+
+1. **Create Base Component Structure**
+ - Implement horizontal workflow progress component
+ - Set up accordion container for sections
+ - Define state management for expansion/collapse
+
+2. **Implement State Management**
+ - Use `Decision.currentStep` to determine initial expansion state
+ - Track completed sections based on decision state
+ - Implement expansion logic that respects workflow progression
+
+3. **Implement Lazy Loading**
+ - Only render content for expanded sections
+ - Load section content on first expansion
+ - Track which sections have been loaded
+
+4. **Implement Section Components**
+ - Refactor existing page components into section components
+ - Ensure all functionality works within the accordion structure
+ - Implement proper state lifting to maintain data consistency
+
+5. **Add Summary Views**
+ - Create condensed summaries for collapsed sections
+ - Show key information to provide context at a glance
+
+6. **Styling and Polish**
+ - Ensure consistent styling across the workflow
+ - Add transitions for smooth section expansion/collapse
+ - Optimize mobile view for the horizontal stepper
+
+7. **Testing**
+ - Test performance impact of lazy loading
+ - Verify workflow progression logic
+ - Ensure all functionality from previous implementation is preserved
+
+## Technical Implementation
+
+```tsx
+// Key state variables
+const [expandedSection, setExpandedSection] = useState(null);
+const [completedSections, setCompletedSections] = useState([]);
+const [loadedSections, setLoadedSections] = useState([]);
+
+// Initialize based on decision.currentStep
+useEffect(() => {
+ if (decision) {
+ const currentStepIndex = DecisionWorkflowSteps.findIndex(
+ step => step.label === decision.currentStep.label
+ );
+ setCompletedSections(Array.from({ length: currentStepIndex }, (_, i) => i));
+ setExpandedSection(currentStepIndex);
+ setLoadedSections([currentStepIndex]);
+ }
+}, [decision]);
+
+// Lazy loading logic
+const shouldLoadSection = (sectionIndex: number) => {
+ return loadedSections.includes(sectionIndex);
+};
+
+// Section expansion handler
+const handleExpandSection = (sectionIndex: number) => {
+ if (completedSections.includes(sectionIndex) ||
+ sectionIndex === Math.min(completedSections.length, DecisionWorkflowSteps.length - 1)) {
+
+ if (!loadedSections.includes(sectionIndex)) {
+ setLoadedSections([...loadedSections, sectionIndex]);
+ }
+
+ setExpandedSection(sectionIndex);
+ }
+};
+```
+
+## Benefits
+
+- **Improved User Experience**: Users can easily reference previous steps without losing context
+- **Maintained Workflow Structure**: Preserves the guided approach of the current implementation
+- **Performance Optimization**: Lazy loading prevents unnecessary content rendering
+- **Consistent Data Model**: Leverages existing domain model's step tracking
+
+## Considerations
+
+- Initial implementation complexity is higher than the multi-page approach
+- Need to ensure UI state properly reflects the underlying decision state
+- Mobile view may require special handling for the horizontal stepper
+- Need to manage form validation consistently across accordions
+
+## Next Steps
+
+1. Create a prototype of the horizontal progress component
+2. Implement the accordion structure with placeholder content
+3. Test the lazy loading performance
+4. Gradually migrate each section from the existing pages
+5. Add comprehensive tests to ensure consistent behavior
\ No newline at end of file
diff --git a/docs/deploy.md b/docs/deploy.md
new file mode 100644
index 0000000..c0933ad
--- /dev/null
+++ b/docs/deploy.md
@@ -0,0 +1,13 @@
+## Deploying the functions
+
+Setup the ADMIN_EMAILS environment variable
+```bash
+echo "admin@example.com,another-admin@example.com" | \
+ firebase functions:secrets:set ADMIN_EMAILS --data-file -
+```
+
+Deploy the functions
+```bash
+pnpm run deploy:functions
+```
+
diff --git a/docs/domain/decision.md b/docs/domain/decision.md
index ef01f7d..76abb7d 100644
--- a/docs/domain/decision.md
+++ b/docs/domain/decision.md
@@ -2,7 +2,7 @@
## Overview
-The Decision is a core entity that represents a structured decision-making process within a project. Decisions follow a defined workflow and can involve multiple stakeholders with different roles. Decisions can be related to each other through blocking/enabling relationships and supersession relationships.
+The Decision is a core entity that represents a structured decision-making process within an organisation. Decisions follow a defined workflow and can involve multiple stakeholders with different roles. Decisions can be related to each other through blocking/enabling relationships and supersession relationships.
### Domain Model Relationships
@@ -10,39 +10,44 @@ The Decision is a core entity that represents a structured decision-making proce
erDiagram
Decision {
string id "Firestore ID"
+ string organisationId "Organisation the decision belongs to"
+ string[] teamIds "Teams associated with the decision"
+ string[] projectIds "Projects associated with the decision"
string title
string description
string status
- string projectId
string driverStakeholderId
- string[] criteria
- string[] options
string decision
string decisionMethod
enum cost "low|medium|high"
enum reversibility "hat|haircut|tattoo"
date createdAt
date updatedAt
+ DecisionRelationshipMap relationships
}
Decision ||--o{ SupportingMaterial : contains
Decision ||--o{ DecisionStakeholderRole : has
DecisionStakeholderRole }o--|| Stakeholder : references
- Decision ||--o{ DecisionRelationship : "has outgoing"
- Decision ||--o{ DecisionRelationship : "has incoming"
+ Decision ||--o{ DecisionRelationship : "has"
+ Decision }o--o{ Team : "labeled with"
+ Decision }o--o{ Project : "labeled with"
```
## Domain Model
```typescript
-type DecisionRelationshipType = "blocks" | "supersedes";
+type DecisionRelationshipType = "blocked_by" | "supersedes" | "blocks" | "superseded_by";
interface DecisionRelationship {
- fromDecisionId: string;
- toDecisionId: string;
+ targetDecision: DocumentReference;
+ targetDecisionTitle: string;
type: DecisionRelationshipType;
- createdAt: Date;
}
+type DecisionRelationshipMap = {
+ [key: string]: DecisionRelationship;
+};
+
interface DecisionProps {
id: string;
title: string;
@@ -59,9 +64,10 @@ interface DecisionProps {
updatedAt?: Date;
driverStakeholderId: string;
supportingMaterials?: SupportingMaterial[];
- // New relationship arrays
- blockedByDecisionIds: string[];
- supersededByDecisionId?: string;
+ organisationId: string;
+ teamIds: string[]; // Multiple teams can be associated with a decision
+ projectIds: string[]; // Multiple projects can be associated with a decision
+ relationships?: DecisionRelationshipMap;
}
class Decision {
@@ -88,46 +94,21 @@ class Decision {
readonly createdAt: Date
readonly updatedAt?: Date
+ // Organization context
+ readonly organisationId: string
+ readonly teamIds: string[] // Multiple teams
+ readonly projectIds: string[] // Multiple projects
+
// Relationship properties
- readonly blockedByDecisionIds: string[]
- readonly supersededByDecisionId?: string
+ readonly relationships?: DecisionRelationshipMap
// Relationship methods
- isBlockedBy(decisionId: string): boolean {
- return this.blockedByDecisionIds.includes(decisionId)
- }
-
- canProceed(completedDecisionIds: string[]): boolean {
- return this.blockedByDecisionIds.every(id => completedDecisionIds.includes(id))
- }
-
- isSuperseded(): boolean {
- return !!this.supersededByDecisionId
- }
-
- // Factory methods for relationships
- addBlockingDecision(blockingDecisionId: string): Decision {
- if (this.blockedByDecisionIds.includes(blockingDecisionId)) {
- return this
- }
-
- return this.with({
- blockedByDecisionIds: [...this.blockedByDecisionIds, blockingDecisionId]
- })
- }
-
- removeBlockingDecision(blockingDecisionId: string): Decision {
- return this.with({
- blockedByDecisionIds: this.blockedByDecisionIds.filter(id => id !== blockingDecisionId)
- })
- }
-
- markAsSupersededBy(newDecisionId: string): Decision {
- return this.with({
- supersededByDecisionId: newDecisionId,
- status: 'superseded'
- })
- }
+ getRelationshipsByType(type: DecisionRelationshipType): DecisionRelationship[]
+ setRelationship(type: DecisionRelationshipType, targetDecision: Decision): Decision
+ unsetRelationship(type: DecisionRelationshipType, targetDecisionId: string): Decision
+ toDocumentReference(): DocumentReference
+ isSuperseded(): boolean
+ isBlocked(): boolean
}
```
@@ -145,7 +126,7 @@ class Decision {
### Stakeholder Roles
```typescript
-type StakeholderRole = "decider" | "advisor" | "observer"
+type StakeholderRole = "decider" | "consulted" | "informed"
interface DecisionStakeholderRole {
stakeholder_id: string
@@ -155,8 +136,14 @@ interface DecisionStakeholderRole {
- Each decision has one driver stakeholder
- Stakeholders can have one of three roles:
- Decider: Can make the final decision
- - Advisor: Can provide input
- - Observer: Can view the decision
+ - Consulted: Can provide input (previously "advisor")
+ - Informed: Can view the decision (previously "observer")
+
+### Team and Project Labels
+- Decisions can be associated with multiple teams and projects
+- These associations are stored as arrays of IDs
+- This flat structure allows for cross-team and cross-project decisions
+- Labels can be added or removed without changing the decision's location
### Cost and Reversibility
- Decisions are classified by their cost impact: low, medium, high
@@ -167,6 +154,20 @@ interface DecisionStakeholderRole {
### Decision Relationships
+#### Relationship Types
+- Four relationship types are supported:
+ - `blocked_by`: This decision is blocked by another decision
+ - `blocks`: This decision blocks another decision
+ - `supersedes`: This decision supersedes another decision
+ - `superseded_by`: This decision is superseded by another decision
+
+#### Relationship Structure
+- Relationships are stored in a map within each decision
+- Each relationship contains:
+ - `targetDecision`: DocumentReference to the related decision
+ - `targetDecisionTitle`: Title of the related decision for display purposes
+ - `type`: Type of relationship
+
#### Blocking/Enabling Relationships
- A decision can be blocked by zero or more other decisions
- A decision cannot proceed until all blocking decisions are completed
@@ -184,29 +185,29 @@ interface DecisionStakeholderRole {
```typescript
interface DecisionScope {
organisationId: string
- teamId: string
- projectId: string
}
interface DecisionsRepository {
getAll(scope: DecisionScope): Promise
getById(id: string, scope: DecisionScope): Promise
+ getByTeam(teamId: string, scope: DecisionScope): Promise
+ getByProject(projectId: string, scope: DecisionScope): Promise
create(initialData: Partial>, scope: DecisionScope): Promise
- update(decision: Decision, scope: DecisionScope): Promise
+ update(decision: Decision): Promise
delete(id: string, scope: DecisionScope): Promise
subscribeToAll(onData: (decisions: Decision[]) => void, onError: (error: Error) => void, scope: DecisionScope): () => void
- subscribeToOne(id: string, onData: (decision: Decision | null) => void, onError: (error: Error) => void, scope: DecisionScope): () => void
+ subscribeToOne(decision: Decision, onData: (decision: Decision | null) => void, onError: (error: Error) => void): () => void
// Relationship methods
- addBlockingRelationship(blockingDecisionId: string, blockedDecisionId: string, scope: DecisionScope): Promise
- removeBlockingRelationship(blockingDecisionId: string, blockedDecisionId: string, scope: DecisionScope): Promise
- markAsSuperseded(oldDecisionId: string, newDecisionId: string, scope: DecisionScope): Promise
-
- // Query methods
- getBlockedDecisions(blockingDecisionId: string, scope: DecisionScope): Promise
- getBlockingDecisions(blockedDecisionId: string, scope: DecisionScope): Promise
- getSupersededDecisions(supersedingDecisionId: string, scope: DecisionScope): Promise
- getSupersedingDecision(supersededDecisionId: string, scope: DecisionScope): Promise
+ addRelationship(
+ sourceDecision: Decision,
+ targetDecisionRelationship: DecisionRelationship,
+ ): Promise;
+
+ removeRelationship(
+ sourceDecision: Decision,
+ targetDecisionRelationship: DecisionRelationship,
+ ): Promise;
}
```
@@ -215,89 +216,46 @@ interface DecisionsRepository {
```sh
organisations/
{organisationId}/
- decisionRelationships/ # Moved to org level to support cross-team/project relationships
- {relationshipId} # Composite of fromDecisionId_toDecisionId
- teams/
- {teamId}/
- projects/
- {projectId}/
- decisions/
- {decisionId}
+ decisions/
+ {decisionId}
```
### Decision Document Structure
```typescript
interface FirestoreDecisionDocument {
// ... existing fields ...
- blockedByDecisionIds: string[]; // Array of decision IDs that block this decision (can be from any team/project in org)
- supersededByDecisionId?: string; // ID of the decision that supersedes this one (can be from any team/project in org)
- // Metadata to support cross-team/project relationships
- organisationId: string; // Added to support quick lookups
- teamId: string; // Added to support quick lookups
- projectId: string; // Added to support quick lookups
-}
-```
-
-### Decision Relationship Document Structure
-```typescript
-interface FirestoreDecisionRelationshipDocument {
- fromDecisionId: string;
- toDecisionId: string;
- type: "blocks" | "supersedes";
- createdAt: Timestamp;
- // Metadata to support cross-team/project relationships
- fromTeamId: string;
- fromProjectId: string;
- toTeamId: string;
- toProjectId: string;
+ organisationId: string;
+ teamIds: string[]; // Array of team IDs
+ projectIds: string[]; // Array of project IDs
+ relationships: {
+ [key: string]: {
+ targetDecision: DocumentReference;
+ targetDecisionTitle: string;
+ type: "blocked_by" | "supersedes" | "blocks" | "superseded_by";
+ }
+ };
}
```
-## Repository Interface
+## Relationship Utilities
```typescript
-interface DecisionsRepository {
- // ... existing methods ...
+export class DecisionRelationshipTools {
+ static getTargetDecisionOrganisationId(decisionRelationship: DecisionRelationship): string {
+ const pathParts = decisionRelationship.targetDecision.path.split('/');
+ const orgIndex = pathParts.indexOf('organisations');
+ return orgIndex >= 0 ? pathParts[orgIndex + 1] : '';
+ }
- // Updated relationship methods with simplified scope (only need organisationId)
- addBlockingRelationship(
- blockingDecisionId: string,
- blockedDecisionId: string,
- organisationId: string
- ): Promise;
-
- removeBlockingRelationship(
- blockingDecisionId: string,
- blockedDecisionId: string,
- organisationId: string
- ): Promise;
-
- markAsSuperseded(
- oldDecisionId: string,
- newDecisionId: string,
- organisationId: string
- ): Promise;
-
- // Updated query methods
- getBlockedDecisions(
- blockingDecisionId: string,
- organisationId: string
- ): Promise;
-
- getBlockingDecisions(
- blockedDecisionId: string,
- organisationId: string
- ): Promise;
-
- getSupersededDecisions(
- supersedingDecisionId: string,
- organisationId: string
- ): Promise;
-
- getSupersedingDecision(
- supersededDecisionId: string,
- organisationId: string
- ): Promise;
+ static getInverseRelationshipType(type: DecisionRelationshipType): DecisionRelationshipType {
+ const lookupInverse: Record = {
+ 'supersedes': 'superseded_by',
+ 'blocked_by': 'blocks',
+ 'blocks': 'blocked_by',
+ 'superseded_by': 'supersedes'
+ }
+ return lookupInverse[type];
+ }
}
```
@@ -307,72 +265,120 @@ interface DecisionsRepository {
// Firestore security rules
match /organisations/{orgId} {
// Helper function to check if user can access a decision
- function canAccessDecision(decisionId) {
- let decision = get(/databases/$(database)/documents/organisations/$(orgId)/teams/*/projects/*/decisions/$(decisionId));
- return decision != null &&
- exists(/databases/$(database)/documents/stakeholderTeams/{stakeholderTeamId}
- where stakeholderTeamId == request.auth.uid
- && organisationId == orgId
- && teamId == decision.data.teamId);
+ function canAccessDecision() {
+ return exists(/databases/$(database)/documents/stakeholderTeams/{stakeholderTeamId}
+ where stakeholderTeamId == request.auth.uid
+ && organisationId == orgId);
}
- match /decisionRelationships/{relationshipId} {
- allow create: if
- // User must have access to both decisions
- canAccessDecision(request.resource.data.fromDecisionId) &&
- canAccessDecision(request.resource.data.toDecisionId) &&
- // Prevent self-referential relationships
- request.resource.data.fromDecisionId != request.resource.data.toDecisionId;
-
- allow read: if
- // User must have access to either the from or to decision
- canAccessDecision(resource.data.fromDecisionId) ||
- canAccessDecision(resource.data.toDecisionId);
+ match /decisions/{decisionId} {
+ allow read: if canAccessDecision();
+ allow create, update: if canAccessDecision();
+ // Additional rules for relationships...
}
}
```
## Usage Examples
-### Creating a Cross-Project Blocking Relationship
+### Creating a Decision with Multiple Teams and Projects
+```typescript
+const decision = await decisionsRepo.create({
+ title: 'Adopt TypeScript',
+ description: 'We should adopt TypeScript for all new projects',
+ cost: 'low',
+ reversibility: 'haircut',
+ teamIds: ['team-engineering', 'team-product'],
+ projectIds: ['project-website', 'project-api'],
+ driverStakeholderId: 'user123',
+ stakeholders: [
+ { stakeholder_id: 'user123', role: 'decider' },
+ { stakeholder_id: 'user456', role: 'consulted' }
+ ],
+ criteria: ['Developer productivity', 'Code quality'],
+ options: ['TypeScript', 'JavaScript', 'Flow'],
+ createdAt: new Date()
+}, { organisationId: 'org1' });
+```
+
+### Creating a Cross-Team Blocking Relationship
```typescript
-// Decision B in Project 2 is blocked by Decision A in Project 1
-await decisionsRepo.addBlockingRelationship(
- decisionA.id, // from Project 1
- decisionB.id, // from Project 2
+// Get the decisions
+const decisionA = await decisionsRepo.getById(decisionAId, {
organisationId
-);
+});
-// Check if Decision B can proceed
const decisionB = await decisionsRepo.getById(decisionBId, {
- organisationId,
- teamId: team2Id,
- projectId: project2Id
+ organisationId
+});
+
+// Use the repository method that adds the relationship to both sides
+const blockingRelationship = {
+ targetDecision: decisionA.toDocumentReference(),
+ targetDecisionTitle: decisionA.title,
+ type: 'blocked_by'
+};
+await decisionsRepo.addRelationship(decisionB, blockingRelationship);
+```
+
+### Filtering Decisions by Team or Project
+```typescript
+// Get all decisions for a specific team
+const teamDecisions = await decisionsRepo.getByTeam('team-engineering', {
+ organisationId: 'org1'
+});
+
+// Get all decisions for a specific project
+const projectDecisions = await decisionsRepo.getByProject('project-website', {
+ organisationId: 'org1'
});
-const canProceed = decisionB.canProceed([decisionA.id]);
```
## Business Rules
-1. Decisions belong to exactly one project
-2. Each decision must have exactly one driver stakeholder
-3. The driver stakeholder cannot be removed from the decision
-4. Stakeholders can only be added once with a specific role
-5. Published decisions cannot be modified
-6. Decision workflow steps must be followed in order
-7. A decision cannot be blocked by itself (direct circular dependency)
-8. Blocking relationships cannot form circular chains
-9. A decision can only be superseded by one other decision
-10. A decision marked as superseded cannot be modified
-11. Supersession relationships cannot form circular chains
-12. A decision cannot proceed until all blocking decisions are completed
-13. Decisions can be related to other decisions within the same organisation, regardless of team or project
-14. Users must have access to both decisions to create a relationship between them
-15. Users can view relationships if they have access to either the source or target decision
+1. Decisions belong to exactly one organisation
+2. Decisions can be associated with multiple teams and projects
+3. Each decision must have exactly one driver stakeholder
+4. The driver stakeholder cannot be removed from the decision
+5. Stakeholders can only be added once with a specific role
+6. Published decisions cannot be modified
+7. Decision workflow steps must be followed in order
+8. A decision cannot be blocked by itself (direct circular dependency)
+9. Blocking relationships cannot form circular chains
+10. A decision can only be superseded by one other decision
+11. A decision marked as superseded cannot be modified
+12. Supersession relationships cannot form circular chains
+13. A decision cannot proceed until all blocking decisions are completed
+14. Decisions can be related to other decisions within the same organisation
+15. Users must have access to the organisation to create or view decisions
+16. Relationships are bidirectional - creating a relationship of type A from decision X to decision Y automatically creates the inverse relationship from Y to X
## Error Handling
```typescript
-// Handled through TypeScript type system and class methods
-// Invalid operations (like removing driver) simply return unchanged decision
-```
\ No newline at end of file
+export class DecisionRelationshipError extends DecisionError {
+ constructor(message: string) {
+ super(message);
+ this.name = 'DecisionRelationshipError';
+ }
+}
+
+// Example usage
+try {
+ // Attempt to create a circular relationship
+ await decisionsRepo.addRelationship(decisionA, {
+ targetDecision: decisionB.toDocumentReference(),
+ targetDecisionTitle: decisionB.title,
+ type: 'blocked_by'
+ });
+ await decisionsRepo.addRelationship(decisionB, {
+ targetDecision: decisionA.toDocumentReference(),
+ targetDecisionTitle: decisionA.title,
+ type: 'blocked_by'
+ });
+} catch (error) {
+ if (error instanceof DecisionRelationshipError) {
+ console.error('Relationship error:', error.message);
+ }
+}
+```
diff --git a/docs/domain/organisation.md b/docs/domain/organisation.md
index 6339491..b373af4 100644
--- a/docs/domain/organisation.md
+++ b/docs/domain/organisation.md
@@ -6,11 +6,10 @@ The Organisation is a top-level entity that represents a security boundary in th
### Domain Model Relationships
+#### Stakeholder relationships
```mermaid
erDiagram
Organisation ||--o{ Team : contains
- Team ||--o{ Project : contains
- Project ||--o{ Decision : contains
Stakeholder }|--o{ StakeholderTeam : "belongs to"
StakeholderTeam }o--|| Team : "references"
@@ -25,23 +24,6 @@ erDiagram
string organisationId
}
- Project {
- string id "Firestore ID"
- string name
- string description
- string teamId
- string organisationId
- }
-
- Decision {
- string id "Firestore ID"
- string title
- string description
- string status
- string projectId
- date publishedAt
- }
-
StakeholderTeam {
string id "Firestore ID"
string stakeholderId
@@ -56,9 +38,15 @@ erDiagram
}
```
+#### Decision relationships
```mermaid
erDiagram
Organisation ||--o{ Team : contains
+ Organisation ||--o{ Project : contains
+ Organisation ||--o{ Decision : contains
+ Decision }o--o{ Team : "labeled with"
+ Decision }o--o{ Project : "labeled with"
+ Decision }o--o{ Stakeholder : "has stakeholders"
Stakeholder }|--o{ StakeholderTeam : "belongs to"
StakeholderTeam }o--|| Team : "references"
@@ -73,6 +61,26 @@ erDiagram
string organisationId
}
+ Project {
+ string id "Firestore ID"
+ string name
+ string description
+ string organisationId
+ }
+
+ Decision {
+ string id "Firestore ID"
+ string title
+ string description
+ string status
+ string organisationId
+ array teamIds
+ array projectIds
+ array stakeholders
+ string driverStakeholderId
+ date publishedAt
+ }
+
StakeholderTeam {
string id "Firestore ID"
string stakeholderId
@@ -130,9 +138,80 @@ class Organisation {
### Teams
- Teams are embedded within organisations
- A team belongs to exactly one organisation
-- Teams contain projects and their associated decisions
- Teams are the primary unit of access control
+### Projects
+- Projects belong directly to an organisation
+- Projects are used as labels for decisions
+- A project can be associated with multiple decisions
+
+### Decisions
+- Decisions belong directly to an organisation
+- Decisions can be associated with multiple teams and projects via labels
+- This flat structure reduces friction when creating decisions
+- Cross-team decisions are easily represented
+
+### Decision Model with Labels
+
+```typescript
+interface DecisionProps {
+ id: string;
+ title: string;
+ description: string;
+ status: DecisionStatus;
+ organisationId: string;
+ teamIds: string[]; // References to teams
+ projectIds: string[]; // References to projects
+ publishedAt?: Date;
+}
+
+class Decision {
+ @IsString()
+ readonly id: string;
+
+ @IsString()
+ @MinLength(3)
+ readonly title: string;
+
+ @IsString()
+ readonly description: string;
+
+ @IsEnum(DecisionStatus)
+ readonly status: DecisionStatus;
+
+ @IsString()
+ readonly organisationId: string;
+
+ @IsArray()
+ @IsString({ each: true })
+ readonly teamIds: string[];
+
+ @IsArray()
+ @IsString({ each: true })
+ readonly projectIds: string[];
+
+ @IsOptional()
+ @IsDate()
+ readonly publishedAt?: Date;
+
+ private constructor(props: DecisionProps) {
+ this.id = props.id;
+ this.title = props.title;
+ this.description = props.description;
+ this.status = props.status;
+ this.organisationId = props.organisationId;
+ this.teamIds = props.teamIds || [];
+ this.projectIds = props.projectIds || [];
+ this.publishedAt = props.publishedAt;
+ this.validate();
+ }
+
+ static create(props: DecisionProps): Decision {
+ return new Decision(props);
+ }
+}
+```
+
### Stakeholder Membership
```typescript
interface StakeholderTeamProps {
@@ -163,10 +242,12 @@ interface OrganisationsRepository {
```sh
organisations/
{organisationId}/
+ decisions/
+ {decisionId}
teams/
- {teamId}/
- projects/
- {projectId}
+ {teamId}
+ projects/
+ {projectId}
stakeholderTeams/
{stakeholderTeamId}
@@ -230,6 +311,19 @@ if (team) {
}
```
+### Creating a Decision with Multiple Teams and Projects
+```typescript
+const decision = Decision.create({
+ id: 'decision-1',
+ title: 'Adopt TypeScript',
+ description: 'We should adopt TypeScript for all new projects',
+ status: DecisionStatus.DRAFT,
+ organisationId: 'org-1',
+ teamIds: ['team-engineering', 'team-product'],
+ projectIds: ['project-website', 'project-api']
+})
+```
+
### Repository Operations
```typescript
// Get organisations for a stakeholder
@@ -247,6 +341,9 @@ const newOrg = await organisationsRepo.create({
- Organisation name must be at least 3 characters
- Teams must be valid Team domain objects
- Teams array can be empty but must be present
+- Decision title must be at least 3 characters
+- Decision must have a valid organisation ID
+- Team and project IDs must reference existing entities
## Business Rules
@@ -256,6 +353,8 @@ const newOrg = await organisationsRepo.create({
4. Organisation names must be unique (enforced at repository level)
5. Organisations can have multiple teams
6. Organisations can be deleted only if they have no teams
+7. Decisions can be associated with multiple teams and projects
+8. All members of an organisation can access all decisions in that organisation
## Error Handling
@@ -272,3 +371,19 @@ if (!org.teams.length) {
throw new OrganisationError('Cannot delete organisation with existing teams')
}
```
+
+## Benefits of Label-Based Approach
+
+1. **Reduced Creation Friction**:
+ - Users can create decisions without choosing a single team/project location
+ - They can easily associate a decision with multiple teams/projects
+ - Teams/projects can be added later as the decision evolves
+
+2. **Improved Cross-Team Collaboration**:
+ - Decisions that span multiple teams are properly represented
+ - Avoids artificially forcing decisions into single-team silos
+ - Better represents the reality of cross-functional decision-making
+
+3. **Flexible Organization**:
+ - Users can view decisions by team, by project, or across the organization
+ - Supports both team-based and project-based workflows
diff --git a/docs/domain/snippets.md b/docs/domain/snippets.md
deleted file mode 100644
index d3abe95..0000000
--- a/docs/domain/snippets.md
+++ /dev/null
@@ -1,78 +0,0 @@
-```typescript
-const createOrganisation = async () => {
- const stakeholderTeamRepo = new FirestoreStakeholderTeamsRepository();
- const stakeholderTeam = await stakeholderTeamRepo.create({
- stakeholderId: user.uid,
- teamId: '3y0NDXZwX9DhmSQVOXag',
- organisationId: 'DwMAq28CCDKgTIWmkYGR'
- })
- console.log(stakeholderTeam);
-
- const org = Organisation.create({
- id: 'new',
- name: 'Mechanical Orchard',
- teams: [
- Team.create({
- id: 'new',
- name: 'MO Leadership',
- organisationId: 'new',
- projects: [
- Project.create({
- id: 'new',
- name: 'MOMP (MO Modernisation Platform)',
- description: 'MOMP is a platform that allows us to modernise our infrastructure',
- teamId: 'new',
- decisions: [Decision.create({
- id: 'new',
- title: 'Which zero access network technology should we use?',
- description: 'eg: Teleport',
- cost: "low",
- createdAt: new Date(),
- criteria: [],
- options: [],
- decision: '',
- decisionMethod: '',
- reversibility: 'hat',
- stakeholders: [],
- status: 'draft',
- user: 'new',
- })]
- })
- ]
- }),
- Team.create({
- id: 'new',
- name: 'Infra',
- organisationId: 'new',
- projects: [
- Project.create({
- id: 'new',
- name: 'MOMP Environment',
- description: 'The environment that runs the modernisation tooling inside the customer\'s network',
- teamId: 'new',
- decisions: [Decision.create({
- id: 'new',
- title: 'Teleport architecture',
- description: 'Description 1',
- cost: "low",
- createdAt: new Date(),
- criteria: [],
- options: [],
- decision: '',
- decisionMethod: '',
- reversibility: 'hat',
- stakeholders: [],
- status: 'draft',
- user: 'new',
- })]
- })
- ]
- }),
- ]
- })
-
- const repository = new FirestoreOrganisationsRepository()
- const org2 = await repository.create(org);
- console.log(org2);
- }
-```
\ No newline at end of file
diff --git a/docs/domain/team_hierarchy.md b/docs/domain/team_hierarchy.md
new file mode 100644
index 0000000..4df419c
--- /dev/null
+++ b/docs/domain/team_hierarchy.md
@@ -0,0 +1,558 @@
+# Team Hierarchy Implementation Plan
+
+## Overview
+
+This document outlines our approach to implementing team hierarchies in the system. The implementation follows a phased approach, starting with a simple solution for organizations with fewer teams and providing a clear path to scale as organizations grow.
+
+## Design Goals
+
+- **Read-optimized**: Prioritize fast reads and real-time subscriptions over write performance
+- **Simple to start**: Begin with the simplest solution that meets current needs
+- **Scalable path**: Provide a clear migration path as organizations grow
+- **Support for visualization**: Enable intuitive UI representation of team relationships
+- **Efficient stakeholder selection**: Make it easy to select entire teams as stakeholders
+- **Security boundaries**: Maintain proper security isolation between different organizations
+
+## Phased Implementation Strategy
+
+### Phase 1: Pure Hierarchical Structure (Current Implementation)
+
+For organizations with fewer than 100 teams, we use a single document containing the entire team hierarchy in a pure hierarchical structure.
+
+#### Data Structure
+
+```typescrip
+// In collection: organisations/{organisationId}/teamHierarchies/hierarchy
+interface TeamHierarchyDocument {
+ rootTeams: Record;
+}
+
+interface HierarchicalTeamNode {
+ id: string;
+ name: string;
+ parentId: string | null;
+ children: Record;
+ // Other team properties
+}
+```
+
+#### Firestore Structure
+
+```
+organisations/
+ {organisationId}/
+ teamHierarchies/
+ hierarchy/ // Single document containing the entire hierarchy
+ rootTeams: {
+ "team-1": {
+ id: "team-1",
+ name: "Leadership Team",
+ parentId: null,
+ children: {
+ "team-2": {
+ id: "team-2",
+ name: "Engineering",
+ parentId: "team-1",
+ children: {
+ // Nested teams
+ }
+ },
+ // More child teams
+ }
+ },
+ // More root teams
+ }
+```
+
+#### Domain Model Structure
+
+In our domain model, we use a flat structure with parent-child relationships for easier manipulation:
+
+```typescrip
+interface TeamHierarchy {
+ teams: Record;
+}
+
+interface TeamHierarchyNode {
+ id: string;
+ name: string;
+ parentId: string | null;
+ children: Record;
+ // Other team properties
+}
+```
+
+The repository layer handles the conversion between the hierarchical Firestore structure and the flat domain model structure.
+
+#### Performance Characteristics
+
+- **Document Size**: ~1KB per team × number of teams
+- **Read Operations**: 1 read for entire hierarchy
+- **Write Operations**: 1 write to update any part of hierarchy
+- **Limits**: Works well for organizations with up to ~800 teams (staying under Firestore's 1MB document limit)
+
+### Phase 2: Preparation for Scaling (When Approaching 100+ Teams)
+
+As organizations grow, we'll enhance the data structure to prepare for future scaling.
+
+#### Enhanced Data Structure
+
+```typescrip
+interface HierarchicalTeamNode {
+ id: string;
+ name: string;
+ parentId: string | null;
+ depth: number; // Added: hierarchy depth (0 for root)
+ path: string; // Added: full path (e.g., "root/engineering/frontend")
+ children: Record;
+}
+```
+
+### Phase 3: Split Hierarchies (For Organizations with 500+ Teams)
+
+For very large organizations, we'll split the hierarchy into multiple documents.
+
+#### Data Structure
+
+```typescrip
+// In collection: organisations/{organisationId}/teamHierarchies/roo
+interface OrganisationHierarchy {
+ rootTeams: {
+ [teamId: string]: {
+ id: string;
+ name: string;
+ childTeamIds: string[]; // References only
+ }
+ }
+}
+
+// In collection: organisations/{organisationId}/teamHierarchies/{teamId}
+interface TeamHierarchyDocument {
+ team: {
+ id: string;
+ name: string;
+ parentId: string | null;
+ depth: number;
+ path: string;
+ // Other team properties
+ };
+ children: {
+ [childTeamId: string]: {
+ id: string;
+ name: string;
+ childTeamIds: string[]; // References only
+ }
+ }
+}
+```
+
+#### Firestore Structure
+
+```
+organisations/
+ {organisationId}/
+ teamHierarchies/
+ root/ // Document containing only root teams
+ rootTeams: {
+ leadership: {
+ id: "team-leadership",
+ name: "Leadership Team",
+ childTeamIds: ["team-eng", "team-product"]
+ },
+ // Other root teams
+ }
+ team-leadership/ // One document per team with its descendants
+ team: {
+ id: "team-leadership",
+ name: "Leadership Team",
+ // Other team data
+ }
+ children: {
+ engineering: {
+ id: "team-eng",
+ name: "Engineering",
+ childTeamIds: ["team-frontend", "team-backend"]
+ }
+ // Other child teams
+ }
+ team-eng/
+ // Team hierarchy for engineering team
+ // More team documents
+```
+
+## Implementation Details
+
+### Reading and Subscribing (Phase 1)
+
+```typescrip
+// Subscribe to the team hierarchy using the hook
+function TeamHierarchyView({ organisationId }) {
+ const { hierarchy, loading, error } = useTeamHierarchy(organisationId);
+
+ if (loading) return Loading...
;
+ if (error) return Error: {error.message}
;
+ if (!hierarchy) return No hierarchy found
;
+
+ // Render the hierarchy
+ return (
+
+ {Object.entries(hierarchy.teams)
+ .filter(([_, team]) => team.parentId === null)
+ .map(([teamId, team]) => (
+
+ ))}
+
+ );
+}
+```
+
+### Updating (Phase 1)
+
+```typescrip
+// Add a new team using the hook
+function AddTeamForm({ organisationId }) {
+ const { addTeam } = useTeamHierarchy(organisationId);
+ const [name, setName] = useState('');
+ const [parentId, setParentId] = useState(null);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ await addTeam({
+ id: `team-${Date.now()}`,
+ name,
+ parentId
+ });
+ setName('');
+ setParentId(null);
+ };
+
+ return (
+
+ );
+}
+```
+
+### Reading and Subscribing (Phase 3)
+
+```typescript
+// Get the initial hierarchy view (root level)
+async function getOrganizationRootHierarchy(orgId: string) {
+ const docRef = doc(db, 'organisations', orgId, 'teamHierarchies', 'root');
+ const docSnap = await getDoc(docRef);
+
+ if (docSnap.exists()) {
+ return docSnap.data().rootTeams || {};
+ }
+ return {};
+}
+
+// Get a specific team hierarchy (for expanding a branch)
+async function getTeamHierarchy(orgId: string, teamId: string) {
+ const docRef = doc(db, 'organisations', orgId, 'teamHierarchies', teamId);
+ const docSnap = await getDoc(docRef);
+
+ if (docSnap.exists()) {
+ return docSnap.data();
+ }
+ return null;
+}
+
+// Subscribe to hierarchy changes
+function subscribeToHierarchy(orgId: string, onUpdate: (hierarchy: any) => void) {
+ // Start with root subscription
+ const rootUnsub = onSnapshot(
+ doc(db, 'organisations', orgId, 'teamHierarchies', 'root'),
+ (docSnap) => {
+ if (docSnap.exists()) {
+ onUpdate(docSnap.data().rootTeams || {});
+ }
+ }
+ );
+
+ // Return function that will be called to unsubscribe
+ return () => {
+ rootUnsub();
+ };
+}
+
+// For expanding a specific branch
+function subscribeToTeamBranch(orgId: string, teamId: string, onUpdate: (hierarchy: any) => void) {
+ return onSnapshot(
+ doc(db, 'organisations', orgId, 'teamHierarchies', teamId),
+ (docSnap) => {
+ if (docSnap.exists()) {
+ onUpdate(docSnap.data());
+ }
+ }
+ );
+}
+```
+
+## Migration from Phase 1 to Phase 3
+
+When an organization grows beyond the capacity of a single document, we'll need to migrate to the split hierarchy approach.
+
+```typescript
+async function migrateToSplitHierarchy(orgId: string) {
+ // 1. Fetch the current monolithic hierarchy
+ const oldHierarchyDoc = await getDoc(doc(db, 'organisations', orgId, 'teamHierarchies', 'hierarchy'));
+ const oldHierarchy = oldHierarchyDoc.data();
+
+ // 2. Create batch for atomic updates
+ const batch = writeBatch(db);
+
+ // 3. Create the new root hierarchy document
+ const rootTeams = {};
+ Object.entries(oldHierarchy.rootTeams)
+ .forEach(([id, team]) => {
+ rootTeams[id] = {
+ id,
+ name: team.name,
+ childTeamIds: Object.keys(team.children || {})
+ };
+ });
+
+ batch.set(doc(db, 'organisations', orgId, 'teamHierarchies', 'root'), { rootTeams });
+
+ // 4. Create individual team hierarchy documents for each subtree
+ function processTeam(teamId, team) {
+ const teamHierarchy = {
+ team: {
+ id: teamId,
+ name: team.name,
+ parentId: team.parentId,
+ depth: team.depth,
+ path: team.path
+ },
+ children: {}
+ };
+
+ // Add immediate children
+ Object.entries(team.children || {}).forEach(([childId, childTeam]) => {
+ teamHierarchy.children[childId] = {
+ id: childId,
+ name: childTeam.name,
+ childTeamIds: Object.keys(childTeam.children || {})
+ };
+
+ // Process each child recursively
+ processTeam(childId, childTeam);
+ });
+
+ // Set the team hierarchy document
+ batch.set(doc(db, 'organisations', orgId, 'teamHierarchies', teamId), teamHierarchy);
+ }
+
+ // Start processing from root teams
+ Object.entries(oldHierarchy.rootTeams)
+ .forEach(([teamId, team]) => processTeam(teamId, team));
+
+ // 5. Commit all the changes
+ await batch.commit();
+}
+```
+
+## UI Implementation
+
+### Team Hierarchy Component
+
+```tsx
+function TeamHierarchyTree({ organisationId, onTeamSelect }) {
+ const { hierarchy, loading, error } = useTeamHierarchy(organisationId);
+ const [expandedTeams, setExpandedTeams] = useState([]);
+
+ if (loading) return Loading...
;
+ if (error) return Error: {error.message}
;
+ if (!hierarchy) return No hierarchy found
;
+
+ const toggleTeam = (teamId: string) => {
+ setExpandedTeams(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ );
+ };
+
+ // Render the hierarchy as a tree
+ const renderTeamNode = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const isExpanded = expandedTeams.includes(teamId);
+
+ return (
+
+
+
onTeamSelect(teamId, checked as boolean)}
+ />
+ toggleTeam(teamId)}
+ >
+ {Object.keys(team.children).length > 0 && (
+ isExpanded ? :
+ )}
+ {team.name}
+
+
+
+ {isExpanded && Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamNode(childId, childTeam, level + 1)
+ )}
+
+ );
+ };
+
+ // Render the root teams
+ return (
+
+ {Object.entries(hierarchy.teams)
+ .filter(([_, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamNode(teamId, team, 0))}
+
+ );
+}
+```
+
+### Stakeholder Selection with Team Hierarchy
+
+```tsx
+function StakeholderSelectionView({ organisationId, selectedStakeholderIds, onStakeholderChange }) {
+ const { hierarchy } = useTeamHierarchy(organisationId);
+ const [stakeholdersByTeam, setStakeholdersByTeam] = useState>({});
+
+ // Fetch hierarchy and stakeholders
+ useEffect(() => {
+ const unsubStakeholders = subscribeToStakeholdersByTeam(organisationId, setStakeholdersByTeam);
+ return () => {
+ unsubStakeholders();
+ };
+ }, [organisationId]);
+
+ // Handle selecting an entire team
+ const handleTeamSelection = (teamId: string, checked: boolean) => {
+ const teamStakeholders = stakeholdersByTeam[teamId] || [];
+
+ teamStakeholders.forEach(stakeholder => {
+ onStakeholderChange(stakeholder.id, checked);
+ });
+ };
+
+ // Render team with its stakeholders
+ const renderTeamWithStakeholders = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const teamStakeholders = stakeholdersByTeam[teamId] || [];
+ const allTeamStakeholdersSelected = teamStakeholders.length > 0 &&
+ teamStakeholders.every(s => selectedStakeholderIds.includes(s.id));
+
+ return (
+
+
+ handleTeamSelection(teamId, checked as boolean)}
+ />
+ {team.name}
+
+ ({teamStakeholders.length} stakeholders)
+
+
+
+
+ {teamStakeholders.map(stakeholder => (
+
+ onStakeholderChange(stakeholder.id, checked as boolean)}
+ />
+ {stakeholder.displayName}
+
+ ))}
+
+
+ {/* Render child teams */}
+
+ {Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamWithStakeholders(childId, childTeam, level + 1)
+ )}
+
+
+ );
+ };
+
+ if (!hierarchy) return Loading...
;
+
+ return (
+
+ {Object.entries(hierarchy.teams)
+ .filter(([_, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamWithStakeholders(teamId, team, 0))}
+
+ );
+}
+```
+
+## Security Rules
+
+```
+match /organisations/{orgId} {
+ // Base organization rules
+
+ match /teamHierarchies/{docId} {
+ // Anyone in the organization can read the hierarchy
+ allow read: if isOrgMember(orgId);
+
+ // Only admins can update the hierarchy
+ allow write: if isOrgAdmin(orgId);
+ }
+}
+```
+
+## Performance Considerations
+
+1. **Document Size Monitoring**:
+ - For Phase 1, monitor the size of the teamHierarchies documents
+ - Consider migration to Phase 3 when documents approach 800KB
+
+2. **Caching Strategy**:
+ - Implement client-side caching for hierarchy data
+ - Use stale-while-revalidate pattern for UI updates
+
+3. **Batch Operations**:
+ - Always use batch operations when updating multiple parts of the hierarchy
+
+4. **Subscription Management**:
+ - Carefully manage subscriptions to avoid memory leaks
+ - Unsubscribe when components unmount
+
+## Security Benefits of Subcollections
+
+Using subcollections under the organization document provides several security benefits:
+
+1. **Simplified Security Rules**: Security rules can be applied at the organization level and cascade to all subcollections
+
+2. **Natural Access Control**: Access to team hierarchies is naturally tied to organization access
+
+3. **Reduced Rule Evaluation**: Firestore evaluates fewer rules when collections are properly nested
+
+4. **Logical Data Grouping**: All organization-related data is grouped together, making it easier to manage
+
+## Future Enhancements
+
+1. **Team Relationship Types**:
+ - Add support for non-hierarchical team relationships (advisory, sibling)
+ - Enhance UI to visualize these relationships
+
+2. **Suggested Stakeholders**:
+ - Implement algorithms to suggest stakeholders from related teams
+ - Highlight leadership teams that should be considered for important decisions
+
+3. **Access Control**:
+ - Implement team-based access control for decisions
+ - Allow restricting visibility of certain teams in the hierarchy
+
+## Conclusion
+
+This phased implementation plan allows us to start with a simple solution that meets our current needs while providing a clear path to scale as organizations grow. By using a pure hierarchical structure in Firestore, we optimize for storage efficiency while maintaining the ease of traversal in our domain model. The repository layer handles the conversion between the hierarchical storage structure and the flat domain model structure, providing a clean separation of concerns.
\ No newline at end of file
diff --git a/docs/stakeholder_ui.md b/docs/stakeholder_ui.md
new file mode 100644
index 0000000..ca11f5e
--- /dev/null
+++ b/docs/stakeholder_ui.md
@@ -0,0 +1,379 @@
+# Stakeholder Selection UI Design
+
+## Overview
+
+This document outlines the design for the stakeholder selection interface, focusing on making it easy for users to select stakeholders for decisions based on team hierarchies. The UI aims to provide an intuitive way to select both individual stakeholders and entire teams, while also prompting users to consider stakeholders from related teams according to organizational structure.
+
+## Objectives
+
+1. **Team-level Selection**: Make it easy to select an entire team of stakeholders for a decision
+2. **Hierarchical Visualization**: Visually represent team relationships to help users understand the organizational structure
+3. **Suggested Stakeholders**: Prompt users to consider stakeholders from leadership teams or related teams
+4. **Intuitive Navigation**: Allow users to easily explore the team hierarchy and find relevant stakeholders
+5. **Visual Feedback**: Provide clear visual cues about selection state and team relationships
+
+## UI Components
+
+### 1. Team Hierarchy Tree
+
+The primary component for visualizing and navigating the team structure:
+
+```tsx
+function TeamHierarchyTree({ organisationId, onTeamSelect }) {
+ const [hierarchy, setHierarchy] = useState(null);
+ const [expandedTeams, setExpandedTeams] = useState([]);
+
+ useEffect(() => {
+ // Subscribe to hierarchy updates
+ const unsubscribe = subscribeToTeamHierarchy(organisationId, setHierarchy);
+ return unsubscribe;
+ }, [organisationId]);
+
+ if (!hierarchy) return Loading...
;
+
+ const toggleTeam = (teamId: string) => {
+ setExpandedTeams(prev =>
+ prev.includes(teamId)
+ ? prev.filter(id => id !== teamId)
+ : [...prev, teamId]
+ );
+ };
+
+ // Render the hierarchy as a tree
+ const renderTeamNode = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const isExpanded = expandedTeams.includes(teamId);
+
+ return (
+
+
+
onTeamSelect(teamId, checked as boolean)}
+ />
+ toggleTeam(teamId)}
+ >
+ {Object.keys(team.children).length > 0 && (
+ isExpanded ? :
+ )}
+ {team.name}
+
+
+
+ {isExpanded && Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamNode(childId, childTeam, level + 1)
+ )}
+
+ );
+ };
+
+ // Render the root teams
+ return (
+
+ {Object.entries(hierarchy.teams)
+ .filter(([_, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamNode(teamId, team, 0))}
+
+ );
+}
+```
+
+### 2. Stakeholder Selection View
+
+A more comprehensive component that combines team hierarchy with stakeholder selection:
+
+```tsx
+function StakeholderSelectionView({ organisationId, selectedStakeholderIds, onStakeholderChange }) {
+ const [hierarchy, setHierarchy] = useState(null);
+ const [stakeholdersByTeam, setStakeholdersByTeam] = useState>({});
+
+ // Fetch hierarchy and stakeholders
+ useEffect(() => {
+ const unsubHierarchy = subscribeToTeamHierarchy(organisationId, setHierarchy);
+ const unsubStakeholders = subscribeToStakeholdersByTeam(organisationId, setStakeholdersByTeam);
+
+ return () => {
+ unsubHierarchy();
+ unsubStakeholders();
+ };
+ }, [organisationId]);
+
+ // Handle selecting an entire team
+ const handleTeamSelection = (teamId: string, checked: boolean) => {
+ const teamStakeholders = stakeholdersByTeam[teamId] || [];
+
+ teamStakeholders.forEach(stakeholder => {
+ onStakeholderChange(stakeholder.id, checked);
+ });
+ };
+
+ // Render team with its stakeholders
+ const renderTeamWithStakeholders = (teamId: string, team: TeamHierarchyNode, level: number) => {
+ const teamStakeholders = stakeholdersByTeam[teamId] || [];
+ const allTeamStakeholdersSelected = teamStakeholders.length > 0 &&
+ teamStakeholders.every(s => selectedStakeholderIds.includes(s.id));
+
+ return (
+
+
+ handleTeamSelection(teamId, checked as boolean)}
+ />
+ {team.name}
+
+ ({teamStakeholders.length} stakeholders)
+
+
+
+
+ {teamStakeholders.map(stakeholder => (
+
+ onStakeholderChange(stakeholder.id, checked as boolean)}
+ />
+ {stakeholder.displayName}
+
+ ))}
+
+
+ {/* Render child teams */}
+
+ {Object.entries(team.children).map(([childId, childTeam]) =>
+ renderTeamWithStakeholders(childId, childTeam, level + 1)
+ )}
+
+
+ );
+ };
+
+ if (!hierarchy) return Loading...
;
+
+ return (
+
+ {Object.entries(hierarchy.teams)
+ .filter(([_, team]) => team.parentId === null)
+ .map(([teamId, team]) => renderTeamWithStakeholders(teamId, team, 0))}
+
+ );
+}
+```
+
+### 3. View Toggle Component
+
+Allow users to switch between different views of the stakeholder selection interface:
+
+```tsx
+function StakeholderViewToggle({ viewMode, setViewMode }) {
+ return (
+
+ setViewMode('list')}
+ >
+
+ List View
+
+ setViewMode('hierarchy')}
+ >
+
+ Hierarchy View
+
+
+ );
+}
+```
+
+### 4. Stakeholder Suggestions Component
+
+Proactively suggest stakeholders from leadership or related teams:
+
+```tsx
+function StakeholderSuggestions({
+ organisationId,
+ selectedTeamIds,
+ selectedStakeholderIds,
+ onStakeholderChange
+}) {
+ const [suggestions, setSuggestions] = useState<{
+ leadership: Stakeholder[];
+ related: Stakeholder[];
+ }>({ leadership: [], related: [] });
+
+ // Generate suggestions based on current selections
+ useEffect(() => {
+ if (selectedTeamIds.length === 0) return;
+
+ // Fetch suggestions based on selected teams
+ const fetchSuggestions = async () => {
+ // Get leadership stakeholders
+ const leadershipStakeholders = await getLeadershipStakeholders(organisationId, selectedTeamIds);
+
+ // Get related team stakeholders
+ const relatedStakeholders = await getRelatedTeamStakeholders(organisationId, selectedTeamIds);
+
+ // Filter out already selected stakeholders
+ const filteredLeadership = leadershipStakeholders.filter(
+ s => !selectedStakeholderIds.includes(s.id)
+ );
+
+ const filteredRelated = relatedStakeholders.filter(
+ s => !selectedStakeholderIds.includes(s.id)
+ );
+
+ setSuggestions({
+ leadership: filteredLeadership,
+ related: filteredRelated
+ });
+ };
+
+ fetchSuggestions();
+ }, [organisationId, selectedTeamIds, selectedStakeholderIds]);
+
+ if (suggestions.leadership.length === 0 && suggestions.related.length === 0) {
+ return null;
+ }
+
+ return (
+
+
Suggested Stakeholders
+
+ {suggestions.leadership.length > 0 && (
+
+
+ Leadership Stakeholders
+
+
+ {suggestions.leadership.map(stakeholder => (
+
+ onStakeholderChange(stakeholder.id, checked as boolean)}
+ />
+ {stakeholder.displayName}
+ Leadership
+
+ ))}
+
+
+ )}
+
+ {suggestions.related.length > 0 && (
+
+
+ Related Team Stakeholders
+
+
+ {suggestions.related.map(stakeholder => (
+
+ onStakeholderChange(stakeholder.id, checked as boolean)}
+ />
+ {stakeholder.displayName}
+ Related
+
+ ))}
+
+
+ )}
+
+ );
+}
+```
+
+## Visual Design
+
+### Team Hierarchy Visualization
+
+The team hierarchy will be visualized as an expandable tree structure:
+
+1. **Root Teams**: Top-level teams displayed at the left margin
+2. **Child Teams**: Indented under their parent teams
+3. **Expansion Controls**: Chevron icons to expand/collapse team hierarchies
+4. **Selection Controls**: Checkboxes for selecting teams and individual stakeholders
+5. **Visual Indicators**:
+ - Team badges to indicate team type (Leadership, Engineering, etc.)
+ - Count of stakeholders per team
+ - Indentation to show hierarchy depth
+
+### Team Relationship Visualization
+
+For organizations with defined team relationships:
+
+1. **Parent-Child**: Represented through indentation and tree structure
+2. **Leadership Teams**: Highlighted with special styling or badges
+3. **Related Teams**: Connected with dotted lines or highlighted when a related team is selected
+4. **Advisory Relationships**: Indicated with special badges or icons
+
+### Selection States
+
+Clear visual feedback for selection states:
+
+1. **Fully Selected Team**: Checkbox checked when all team members are selected
+2. **Partially Selected Team**: Indeterminate checkbox state when some team members are selected
+3. **Selected Stakeholder**: Checked checkbox for individual stakeholders
+4. **Suggested Stakeholders**: Highlighted with a different background color or badge
+
+## Interaction Patterns
+
+### Team Selection
+
+1. **Select All**: Clicking a team's checkbox selects all stakeholders in that team
+2. **Partial Selection**: Selecting individual stakeholders updates the team's checkbox state
+3. **Hierarchical Selection**: Option to select a team and all its sub-teams
+
+### Navigation
+
+1. **Expand/Collapse**: Click on chevron icons to expand or collapse team hierarchies
+2. **Search**: Filter stakeholders and teams by name or role
+3. **View Modes**: Toggle between different views (list, hierarchy, etc.)
+
+### Suggestions
+
+1. **Automatic Suggestions**: Based on selected teams, suggest stakeholders from:
+ - Leadership teams
+ - Related teams
+ - Teams that frequently collaborate
+2. **Contextual Prompts**: Display messages encouraging consideration of key stakeholders
+
+## Implementation Considerations
+
+### Performance
+
+1. **Lazy Loading**: For large organizations, load team details on demand
+2. **Virtualized Lists**: Use virtualization for long lists of stakeholders
+3. **Efficient Rendering**: Minimize re-renders when selection state changes
+
+### Accessibility
+
+1. **Keyboard Navigation**: Full keyboard support for navigating the hierarchy
+2. **Screen Reader Support**: Proper ARIA attributes for tree structure
+3. **Focus Management**: Clear focus indicators and logical tab order
+
+### Responsive Design
+
+1. **Mobile View**: Simplified view for small screens
+2. **Touch Targets**: Larger touch targets for mobile users
+3. **Progressive Disclosure**: Show less information initially on small screens
+
+## Future Enhancements
+
+1. **Stakeholder Avatars**: Display profile pictures for stakeholders
+2. **Team Analytics**: Show statistics about team involvement in decisions
+3. **Stakeholder Roles**: Indicate stakeholder roles within teams
+4. **Custom Views**: Allow users to save custom views of the hierarchy
+5. **Drag and Drop**: Allow reorganizing teams via drag and drop
+6. **Relationship Visualization**: More sophisticated visualization of team relationships
+
+## Conclusion
+
+This stakeholder selection UI design provides an intuitive way for users to select stakeholders based on team hierarchies. By visualizing the organizational structure and providing team-level selection capabilities, we make it easier for users to include all relevant stakeholders in their decisions. The suggestion mechanism helps ensure that important stakeholders from leadership or related teams are not overlooked.
\ No newline at end of file
diff --git a/firebase.json b/firebase.json
index 3b9288c..19ad6c6 100644
--- a/firebase.json
+++ b/firebase.json
@@ -1,15 +1,4 @@
{
- "hosting": {
- "source": ".",
- "ignore": [
- "firebase.json",
- "**/.*",
- "**/node_modules/**"
- ],
- "frameworksBackend": {
- "region": "europe-west1"
- }
- },
"functions": [
{
"source": "lib/infrastructure/firebase/functions",
@@ -35,6 +24,9 @@
"firestore": {
"port": 8080
},
+ "functions": {
+ "port": 5001
+ },
"ui": {
"enabled": true
},
diff --git a/firestore.rules b/firestore.rules
index a582390..384f07f 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -2,11 +2,53 @@ rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
- // Allow read/write access to any authenticated user
- // TODO: Add additional restrictions to only allow users to access their own organizations' documents
+ // Helper function to check if user is admin
+ function isAdmin() {
+ return request.auth.token.admin == true;
+ }
+
+ // Helper function to check if user can access an organisation
+ function canAccessOrganisation(orgId) {
+ return exists(/databases/$(database)/documents/stakeholderTeams/$(request.auth.uid))
+ && get(/databases/$(database)/documents/stakeholderTeams/$(request.auth.uid)).data.organisationId == orgId;
+ }
+
+ match /organisations/{orgId} {
+ // Allow read if user is a member of the organisation or is admin
+ allow read: if canAccessOrganisation(orgId) || isAdmin();
+
+ // Teams collection
+ match /teams/{teamId} {
+ allow read, write: if canAccessOrganisation(orgId) || isAdmin();
+ }
+
+ // Team hierarchies collection
+ match /teamHierarchies/{hierarchyId} {
+ allow read: if canAccessOrganisation(orgId);
+ allow write: if isAdmin();
+ }
+
+ // Projects collection - directly under organisation
+ match /projects/{projectId} {
+ allow read, write: if canAccessOrganisation(orgId) || isAdmin();
+ }
+
+ // Decisions collection - directly under organisation
+ match /decisions/{decisionId} {
+ allow read, write: if canAccessOrganisation(orgId) || isAdmin();
+ }
+ }
+
+ // StakeholderTeams collection
+ match /stakeholderTeams/{stakeholderTeamId} {
+ allow read: if request.auth.uid == stakeholderTeamId || isAdmin();
+ allow write: if request.auth.uid == stakeholderTeamId || isAdmin();
+ }
+
+ // Allow all operations when running in the emulator
match /{document=**} {
- allow read, write: if request.auth != null;
+ allow read: if request.auth != null;
+ allow write: if request.auth != null && request.resource.data.test == true;
}
-
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/hooks/useAdminAuth.ts b/hooks/useAdminAuth.ts
new file mode 100644
index 0000000..5a387fb
--- /dev/null
+++ b/hooks/useAdminAuth.ts
@@ -0,0 +1,45 @@
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+
+interface AdminAuthState {
+ isLoading: boolean
+ isAdmin: boolean
+ error?: string
+}
+
+export function useAdminAuth() {
+ const router = useRouter()
+ const [state, setState] = useState({
+ isLoading: true,
+ isAdmin: false,
+ })
+
+ useEffect(() => {
+ async function checkAdminAuth() {
+ try {
+ const response = await fetch('/api/admin/settings/auth')
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ router.push('/login')
+ } else if (response.status === 403) {
+ router.push('/unauthorized')
+ }
+ throw new Error('Admin authentication failed')
+ }
+
+ setState({ isLoading: false, isAdmin: true })
+ } catch (error) {
+ setState({
+ isLoading: false,
+ isAdmin: false,
+ error: error instanceof Error ? error.message : 'Authentication failed'
+ })
+ }
+ }
+
+ checkAdminAuth()
+ }, [router])
+
+ return state
+}
\ No newline at end of file
diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts
index 4a4e03b..92e1a62 100644
--- a/hooks/useAuth.ts
+++ b/hooks/useAuth.ts
@@ -1,15 +1,16 @@
// hooks/useAuth.ts
-import { useState, useEffect } from 'react';
-import { User, onAuthStateChanged } from 'firebase/auth';
-import { auth } from '../lib/firebase';
-import { FirestoreStakeholdersRepository } from '@/lib/infrastructure/firestoreStakeholdersRepository';
+import { useState, useEffect } from "react";
+import { User, onAuthStateChanged } from "firebase/auth";
+import { auth } from "@/lib/firebase";
+import { FirestoreStakeholdersRepository } from "@/lib/infrastructure/firestoreStakeholdersRepository";
/**
- * Single Responsibility: keeps track of the current Firebase Auth user
+ * Keeps track of the current Firebase Auth user and manages admin session
*/
export function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
+ const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
@@ -17,6 +18,45 @@ export function useAuth() {
if (firebaseUser) {
await stakeholderRepository.updateStakeholderForUser(firebaseUser);
+ try {
+ // Get the current token
+ const idToken = await firebaseUser.getIdToken();
+
+ // Create session cookie
+ await fetch('/api/auth/session', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ idToken }),
+ });
+
+ // Check admin status
+ const response = await fetch('/api/auth/check-admin', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ idToken }),
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ setIsAdmin(result.isAdmin === true);
+ } else {
+ console.error('Failed to check admin status:', await response.text());
+ setIsAdmin(false);
+ }
+ } catch (error) {
+ console.error('Error checking admin status:', error);
+ setIsAdmin(false);
+ }
+ } else {
+ setIsAdmin(false);
+ // Clear session cookie on sign out
+ await fetch('/api/auth/session', {
+ method: 'DELETE',
+ });
}
setUser(firebaseUser);
@@ -26,5 +66,5 @@ export function useAuth() {
return () => unsubscribe();
}, []);
- return { user, loading };
-}
\ No newline at end of file
+ return { user, loading, isAdmin };
+}
diff --git a/hooks/useDecisionRelationships.ts b/hooks/useDecisionRelationships.ts
index 7974e61..6455071 100644
--- a/hooks/useDecisionRelationships.ts
+++ b/hooks/useDecisionRelationships.ts
@@ -1,53 +1,68 @@
-import { useState } from 'react'
-import { DecisionRelationship, DecisionRelationshipType } from '@/lib/domain/DecisionRelationship'
-import { FirestoreDecisionRelationshipRepository } from '@/lib/infrastructure/firestoreDecisionRelationshipRepository'
-import { Decision } from '@/lib/domain/Decision'
-
-const decisionRelationshipRepository = new FirestoreDecisionRelationshipRepository();
+import { Decision, DecisionRelationshipType, DecisionRelationship } from '@/lib/domain/Decision'
+import { FirestoreDecisionsRepository } from '@/lib/infrastructure/firestoreDecisionsRepository'
export interface SelectedDecisionDetails {
- toDecisionId: string
- toTeamId: string
- toProjectId: string
- organisationId: string
+ toDecisionId: string;
+ organisationId: string;
}
-export function useDecisionRelationships(fromDecision: Decision) {
- const [error, setError] = useState(null)
+const decisionRepository = new FirestoreDecisionsRepository();
- const addRelationship = async (toDecision: SelectedDecisionDetails, type: DecisionRelationshipType) => {
+export function useDecisionRelationships(sourceDecision: Decision) {
+ const addRelationship = async (targetDetails: SelectedDecisionDetails, type: DecisionRelationshipType) => {
try {
- const relationship = DecisionRelationship.create({
- fromDecisionId: fromDecision.id,
- toDecisionId: toDecision.toDecisionId,
- type,
+ const targetDecision = Decision.create({
+ id: targetDetails.toDecisionId,
+ title: '',
+ description: '',
+ cost: 'low',
createdAt: new Date(),
- fromTeamId: fromDecision.teamId,
- fromProjectId: fromDecision.projectId,
- toTeamId: toDecision.toTeamId,
- toProjectId: toDecision.toProjectId,
- organisationId: fromDecision.organisationId
+ reversibility: 'hat',
+ stakeholders: [],
+ driverStakeholderId: '',
+ organisationId: targetDetails.organisationId,
+ teamIds: [],
+ projectIds: [],
+ supportingMaterials: []
});
- await decisionRelationshipRepository.addRelationship(relationship)
+ // Create a DecisionRelationship object
+ const relationship: DecisionRelationship = {
+ targetDecision: targetDecision.toDocumentReference(),
+ targetDecisionTitle: targetDecision.title || 'Unknown Decision',
+ type: type
+ };
+
+ await decisionRepository.addRelationship(
+ sourceDecision,
+ relationship
+ );
} catch (error) {
- setError(error as Error)
- throw error
+ console.error('Error adding relationship:', error);
+ throw error;
}
- }
+ };
- const removeRelationship = async (relationship: DecisionRelationship) => {
+ const removeRelationship = async (type: DecisionRelationshipType, targetDecision: Decision) => {
try {
- await decisionRelationshipRepository.removeRelationship(relationship)
+ // Create a DecisionRelationship object
+ const relationship: DecisionRelationship = {
+ targetDecision: targetDecision.toDocumentReference(),
+ targetDecisionTitle: targetDecision.title,
+ type: type
+ };
+ await decisionRepository.removeRelationship(
+ sourceDecision,
+ relationship
+ );
} catch (error) {
- setError(error as Error)
- throw error
+ console.error('Error removing relationship:', error);
+ throw error;
}
- }
+ };
return {
- error,
addRelationship,
- removeRelationship
- }
-}
\ No newline at end of file
+ removeRelationship,
+ };
+}
\ No newline at end of file
diff --git a/hooks/useDecisions.ts b/hooks/useDecisions.ts
index dc933c1..fb91393 100644
--- a/hooks/useDecisions.ts
+++ b/hooks/useDecisions.ts
@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
-import { useParams } from "next/navigation";
import {
Decision,
Cost,
@@ -13,42 +12,51 @@ import { DecisionScope } from "@/lib/domain/decisionsRepository";
const decisionsRepository = new FirestoreDecisionsRepository();
-export function useDecision(decisionId: string) {
- const params = useParams();
- const organisationId = params.organisationId as string;
- const teamId = params.teamId as string;
- const projectId = params.projectId as string;
-
+export function useDecision(decisionId: string, organisationId: string) {
const [decision, setDecision] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const scope: DecisionScope = { organisationId, teamId, projectId };
-
useEffect(() => {
- const unsubscribe = decisionsRepository.subscribeToOne(
- decisionId,
- (decision: Decision | null) => {
- setDecision(decision);
+ let unsubscribe: () => void;
+ const fetchDecision = async () => {
+ try {
+ // First get the decision by ID to ensure we have a valid Decision object
+ const scope: DecisionScope = { organisationId };
+ const fetchedDecision = await decisionsRepository.getById(decisionId, scope);
+ // Then subscribe to updates
+ unsubscribe = decisionsRepository.subscribeToOne(
+ fetchedDecision,
+ (decision: Decision | null) => {
+ setDecision(decision);
+ setLoading(false);
+ setError(null);
+ },
+ (error: Error) => {
+ setError(error);
+ setLoading(false);
+ }
+ );
+ } catch (err) {
+ setError(err as Error);
setLoading(false);
- setError(null);
- },
- (error: Error) => {
- setError(error);
- setLoading(false);
- },
- scope,
- );
+ }
+ }
- return () => unsubscribe();
- }, [organisationId, teamId, projectId, decisionId]);
+ fetchDecision();
+
+ return () => {
+ if (unsubscribe) {
+ unsubscribe();
+ }
+ };
+ }, [decisionId, organisationId]);
const updateDecisionTitle = async (title: string) => {
try {
if (!decision) return;
await decisionsRepository.update(
decision.with({ title }),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -61,7 +69,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.with({ description }),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -74,7 +81,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.with({ cost }),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -87,7 +93,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.with({ reversibility }),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -100,7 +105,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.setDecisionDriver(driverStakeholderId),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -113,7 +117,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.with({ stakeholders }),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -129,7 +132,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.addStakeholder(stakeholderId, role),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -142,7 +144,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.removeStakeholder(stakeholderId),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -155,7 +156,6 @@ export function useDecision(decisionId: string) {
if (!decision) return;
await decisionsRepository.update(
decision.with({ decisionMethod: method }),
- scope,
);
} catch (error) {
setError(error as Error);
@@ -163,12 +163,11 @@ export function useDecision(decisionId: string) {
}
};
- const updateDecisionOptions = async (options: string[]) => {
+ const updateDecisionContent = async (content: string) => {
try {
if (!decision) return;
await decisionsRepository.update(
- decision.with({ options }),
- scope,
+ decision.with({ decision: content }),
);
} catch (error) {
setError(error as Error);
@@ -176,12 +175,11 @@ export function useDecision(decisionId: string) {
}
};
- const updateDecisionCriteria = async (criteria: string[]) => {
+ const updateSupportingMaterials = async (materials: SupportingMaterial[]) => {
try {
if (!decision) return;
await decisionsRepository.update(
- decision.with({ criteria }),
- scope,
+ decision.with({ supportingMaterials: materials }),
);
} catch (error) {
setError(error as Error);
@@ -189,12 +187,12 @@ export function useDecision(decisionId: string) {
}
};
- const updateDecisionContent = async (content: string) => {
+ const addSupportingMaterial = async (material: SupportingMaterial) => {
try {
if (!decision) return;
+ const newMaterials = [...(decision.supportingMaterials || []), material];
await decisionsRepository.update(
- decision.with({ decision: content }),
- scope,
+ decision.with({ supportingMaterials: newMaterials }),
);
} catch (error) {
setError(error as Error);
@@ -202,12 +200,12 @@ export function useDecision(decisionId: string) {
}
};
- const updateSupportingMaterials = async (materials: SupportingMaterial[]) => {
+ const removeSupportingMaterial = async (materialUrl: string) => {
try {
if (!decision) return;
+ const newMaterials = decision.supportingMaterials.filter(m => m.url !== materialUrl);
await decisionsRepository.update(
- decision.with({ supportingMaterials: materials }),
- scope,
+ decision.with({ supportingMaterials: newMaterials }),
);
} catch (error) {
setError(error as Error);
@@ -215,13 +213,11 @@ export function useDecision(decisionId: string) {
}
};
- const addSupportingMaterial = async (material: SupportingMaterial) => {
+ const publishDecision = async () => {
try {
if (!decision) return;
- const newMaterials = [...(decision.supportingMaterials || []), material];
await decisionsRepository.update(
- decision.with({ supportingMaterials: newMaterials }),
- scope,
+ decision.publish()
);
} catch (error) {
setError(error as Error);
@@ -229,13 +225,16 @@ export function useDecision(decisionId: string) {
}
};
- const removeSupportingMaterial = async (materialUrl: string) => {
+ /**
+ * Updates the notes associated with a decision
+ * @param decisionNotes - The new notes content in HTML format
+ * @throws {Error} If the update fails or if no decision is loaded
+ */
+ const updateDecisionNotes = async (decisionNotes: string) => {
try {
if (!decision) return;
- const newMaterials = decision.supportingMaterials.filter(m => m.url !== materialUrl);
await decisionsRepository.update(
- decision.with({ supportingMaterials: newMaterials }),
- scope,
+ decision.with({ decisionNotes }),
);
} catch (error) {
setError(error as Error);
@@ -256,11 +255,11 @@ export function useDecision(decisionId: string) {
updateDecisionMethod,
addStakeholder,
removeStakeholder,
- updateDecisionOptions,
- updateDecisionCriteria,
updateDecisionContent,
updateSupportingMaterials,
addSupportingMaterial,
removeSupportingMaterial,
+ publishDecision,
+ updateDecisionNotes,
};
}
diff --git a/hooks/useOrganisation.ts b/hooks/useOrganisation.ts
new file mode 100644
index 0000000..2a2f927
--- /dev/null
+++ b/hooks/useOrganisation.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react'
+import { Organisation } from '@/lib/domain/Organisation'
+import { db } from '@/lib/firebase'
+import { collection, doc, onSnapshot } from 'firebase/firestore'
+
+interface OrganisationState {
+ organisation?: Organisation
+ loading: boolean
+ error?: Error
+}
+
+export function useOrganisation() {
+ const [state, setState] = useState({
+ loading: true
+ })
+
+ useEffect(() => {
+ // For now, we'll just get the first organisation
+ // TODO: Add support for multiple organisations
+ const unsubscribe = onSnapshot(
+ collection(db, 'organisations'),
+ (snapshot) => {
+ if (snapshot.empty) {
+ setState({
+ loading: false,
+ error: new Error('No organisations found')
+ })
+ return
+ }
+
+ const firstOrg = snapshot.docs[0]
+ setState({
+ organisation: Organisation.create({
+ id: firstOrg.id,
+ ...firstOrg.data()
+ }),
+ loading: false
+ })
+ },
+ (error) => {
+ setState({
+ loading: false,
+ error: error as Error
+ })
+ }
+ )
+
+ return () => unsubscribe()
+ }, [])
+
+ return state
+}
\ No newline at end of file
diff --git a/hooks/useProjectDecisions.ts b/hooks/useOrganisationDecisions.ts
similarity index 84%
rename from hooks/useProjectDecisions.ts
rename to hooks/useOrganisationDecisions.ts
index 0960131..4db355f 100644
--- a/hooks/useProjectDecisions.ts
+++ b/hooks/useOrganisationDecisions.ts
@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
-import { useParams } from "next/navigation";
import { Decision } from "@/lib/domain/Decision";
import { FirestoreDecisionsRepository } from "@/lib/infrastructure/firestoreDecisionsRepository";
import { DecisionScope } from "@/lib/domain/decisionsRepository";
@@ -8,20 +7,20 @@ import { FirestoreStakeholdersRepository } from "@/lib/infrastructure/firestoreS
const decisionsRepository = new FirestoreDecisionsRepository();
-export function useProjectDecisions() {
- const params = useParams();
- const organisationId = params.organisationId as string;
- const teamId = params.teamId as string;
- const projectId = params.projectId as string;
-
+export function useOrganisationDecisions(organisationId: string) {
const [decisions, setDecisions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { user } = useAuth();
- const scope: DecisionScope = { organisationId, teamId, projectId };
+ const scope: DecisionScope = { organisationId };
useEffect(() => {
+ if (!organisationId) {
+ setLoading(false);
+ return () => {};
+ }
+
const unsubscribe = decisionsRepository.subscribeToAll(
(decisions) => {
setDecisions(decisions);
@@ -36,7 +35,7 @@ export function useProjectDecisions() {
);
return () => unsubscribe();
- }, [organisationId, teamId, projectId]);
+ }, [organisationId]);
const createDecision = async () => {
try {
@@ -50,8 +49,6 @@ export function useProjectDecisions() {
}
const newDecision = Decision.createEmptyDecision({
organisationId,
- teamId,
- projectId,
});
const decisionWithDriver = newDecision.setDecisionDriver(userStakeholder.id);
return await decisionsRepository.create(decisionWithDriver.withoutId(), scope);
diff --git a/hooks/useOrganisations.ts b/hooks/useOrganisations.ts
index 1dbece0..4e291a0 100644
--- a/hooks/useOrganisations.ts
+++ b/hooks/useOrganisations.ts
@@ -7,13 +7,30 @@ export function useOrganisations() {
const [organisations, setOrganisations] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
- const { user } = useAuth()
+ const { user, isAdmin } = useAuth()
+
const addOrganisation = async (organisation: OrganisationProps) => {
const repository = new FirestoreOrganisationsRepository()
const newOrg = await repository.create(organisation)
setOrganisations([...organisations, newOrg])
}
+ const fetchAllOrganisations = async () => {
+ if (!isAdmin) {
+ throw new Error('Only admin users can fetch all organisations')
+ }
+
+ try {
+ const repository = new FirestoreOrganisationsRepository()
+ const allOrgs = await repository.getAll()
+ setOrganisations(allOrgs)
+ } catch (err) {
+ console.error(err)
+ setError(err instanceof Error ? err : new Error('Failed to fetch all organisations'))
+ throw err
+ }
+ }
+
useEffect(() => {
if (!user?.email) return
const fetchOrganisation = async () => {
@@ -35,5 +52,5 @@ export function useOrganisations() {
fetchOrganisation()
}, [user?.email])
- return { organisations, setOrganisations, addOrganisation, loading, error }
+ return { organisations, setOrganisations, addOrganisation, fetchAllOrganisations, loading, error }
}
diff --git a/hooks/useStakeholderTeams.ts b/hooks/useStakeholderTeams.ts
index 7f9f299..5a74507 100644
--- a/hooks/useStakeholderTeams.ts
+++ b/hooks/useStakeholderTeams.ts
@@ -6,6 +6,7 @@ import { FirestoreOrganisationsRepository } from '@/lib/infrastructure/firestore
export function useStakeholderTeams() {
const [stakeholderTeams, setStakeholderTeams] = useState([])
+ const [stakeholderTeamsMap, setStakeholderTeamsMap] = useState>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const { user } = useAuth()
@@ -23,6 +24,16 @@ export function useStakeholderTeams() {
const stakeholderOrgs = await orgRepository.getForStakeholder(user.email);
const teams = await stakeholderTeamsRepository.getByOrganisation(stakeholderOrgs);
setStakeholderTeams(teams)
+
+ // Create a map of stakeholder IDs to team IDs
+ const teamsMap: Record = {};
+ teams.forEach(team => {
+ if (!teamsMap[team.stakeholderId]) {
+ teamsMap[team.stakeholderId] = [];
+ }
+ teamsMap[team.stakeholderId].push(team.teamId);
+ });
+ setStakeholderTeamsMap(teamsMap);
} catch (err) {
console.error(err)
setError(err instanceof Error ? err : new Error('Failed to fetch stakeholder teams'))
@@ -38,6 +49,14 @@ export function useStakeholderTeams() {
const repository = new FirestoreStakeholderTeamsRepository()
const newTeam = await repository.create(props)
setStakeholderTeams([...stakeholderTeams, newTeam])
+ setStakeholderTeamsMap(prev => {
+ const newMap = { ...prev };
+ if (!newMap[newTeam.stakeholderId]) {
+ newMap[newTeam.stakeholderId] = [];
+ }
+ newMap[newTeam.stakeholderId].push(newTeam.teamId);
+ return newMap;
+ });
}
const removeStakeholderTeam = async (stakeholderId: string, teamId: string) => {
@@ -46,8 +65,18 @@ export function useStakeholderTeams() {
if (existingTeam) {
await repository.delete(existingTeam.id)
setStakeholderTeams(stakeholderTeams.filter(st => st.id !== existingTeam.id))
+ setStakeholderTeamsMap(prev => {
+ const newMap = { ...prev };
+ if (newMap[stakeholderId]) {
+ newMap[stakeholderId] = newMap[stakeholderId].filter(id => id !== teamId);
+ if (newMap[stakeholderId].length === 0) {
+ delete newMap[stakeholderId];
+ }
+ }
+ return newMap;
+ });
}
}
- return { stakeholderTeams, setStakeholderTeams, addStakeholderTeam, removeStakeholderTeam, loading, error }
+ return { stakeholderTeams, stakeholderTeamsMap, loading, error, addStakeholderTeam, removeStakeholderTeam }
}
\ No newline at end of file
diff --git a/hooks/useTeamHierarchy.ts b/hooks/useTeamHierarchy.ts
new file mode 100644
index 0000000..793bd96
--- /dev/null
+++ b/hooks/useTeamHierarchy.ts
@@ -0,0 +1,104 @@
+import { useState, useEffect } from 'react'
+import { TeamHierarchy, TeamHierarchyNode } from '@/lib/domain/TeamHierarchy'
+
+interface UseTeamHierarchyResult {
+ hierarchy: TeamHierarchy | null
+ loading: boolean
+ error: Error | null
+ addTeam: (team: TeamHierarchyNode) => Promise
+ removeTeam: (teamId: string) => Promise
+ updateTeam: (teamId: string, team: TeamHierarchyNode) => Promise
+}
+
+/**
+ * Hook for subscribing to and managing team hierarchies
+ * @param organisationId The ID of the organisation
+ * @returns An object with the hierarchy, loading state, error state, and methods for managing the hierarchy
+ */
+export function useTeamHierarchy(organisationId: string): UseTeamHierarchyResult {
+ const [hierarchy, setHierarchy] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ async function fetchHierarchy() {
+ try {
+ const response = await fetch(`/api/admin/organisations/${organisationId}/team-hierarchy`)
+ if (!response.ok) throw new Error('Failed to fetch team hierarchy')
+ const data = await response.json()
+ setHierarchy(data)
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Unknown error'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchHierarchy()
+ }, [organisationId])
+
+ const addTeam = async (team: TeamHierarchyNode) => {
+ try {
+ const response = await fetch(`/api/admin/organisations/${organisationId}/team-hierarchy/teams`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(team)
+ })
+
+ if (!response.ok) throw new Error('Failed to add team')
+
+ const updatedHierarchy = await response.json()
+ setHierarchy(updatedHierarchy)
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to add team'))
+ throw err
+ }
+ }
+
+ const removeTeam = async (teamId: string) => {
+ try {
+ const response = await fetch(
+ `/api/admin/organisations/${organisationId}/team-hierarchy/teams/${teamId}`,
+ { method: 'DELETE' }
+ )
+
+ if (!response.ok) throw new Error('Failed to remove team')
+
+ const updatedHierarchy = await response.json()
+ setHierarchy(updatedHierarchy)
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to remove team'))
+ throw err
+ }
+ }
+
+ const updateTeam = async (teamId: string, team: TeamHierarchyNode) => {
+ try {
+ const response = await fetch(
+ `/api/admin/organisations/${organisationId}/team-hierarchy/teams/${teamId}`,
+ {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(team)
+ }
+ )
+
+ if (!response.ok) throw new Error('Failed to update team')
+
+ const updatedHierarchy = await response.json()
+ setHierarchy(updatedHierarchy)
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to update team'))
+ throw err
+ }
+ }
+
+ return {
+ hierarchy,
+ loading,
+ error,
+ addTeam,
+ removeTeam,
+ updateTeam
+ }
+}
\ No newline at end of file
diff --git a/lib/adminApiRoute.ts b/lib/adminApiRoute.ts
new file mode 100644
index 0000000..ebc6137
--- /dev/null
+++ b/lib/adminApiRoute.ts
@@ -0,0 +1,57 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { adminAuth } from '@/lib/firebase-admin';
+
+type AdminRouteHandler = (
+ req: NextRequest,
+ context: { uid: string; params?: { [key: string]: string } }
+) => Promise | NextResponse;
+
+/**
+ * Higher-order function that wraps an API route handler with admin authentication
+ *
+ * @param handler - The route handler to wrap
+ * @returns A wrapped handler that checks for admin access
+ *
+ * @example
+ * ```typescript
+ * // app/api/admin/some-endpoint/route.ts
+ * export const POST = withAdminAuth(async (req, { uid, params }) => {
+ * // Your admin-only logic here
+ * return NextResponse.json({ success: true });
+ * });
+ * ```
+ */
+export function withAdminAuth(handler: AdminRouteHandler) {
+ return async (req: NextRequest, context?: { params?: { [key: string]: string } }) => {
+ try {
+ // Get the session cookie
+ const sessionCookie = req.cookies.get('session')?.value;
+
+ if (!sessionCookie) {
+ return NextResponse.json(
+ { error: 'Unauthorized - No session' },
+ { status: 401 }
+ );
+ }
+
+ // Verify the session cookie and check admin claim
+ const decodedClaims = await adminAuth.verifySessionCookie(sessionCookie, true);
+
+ if (decodedClaims.admin !== true) {
+ return NextResponse.json(
+ { error: 'Forbidden - Not an admin' },
+ { status: 403 }
+ );
+ }
+
+ // Call the handler with the verified user ID and params
+ return handler(req, { uid: decodedClaims.uid, params: context?.params });
+ } catch (error) {
+ console.error('Admin API route error:', error);
+ return NextResponse.json(
+ { error: 'Authentication failed' },
+ { status: 401 }
+ );
+ }
+ };
+}
\ No newline at end of file
diff --git a/lib/authFunctions.ts b/lib/authFunctions.ts
index 9e27780..f962a35 100644
--- a/lib/authFunctions.ts
+++ b/lib/authFunctions.ts
@@ -1,5 +1,10 @@
-import { GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth';
-import { auth } from './firebase';
+import {
+ GoogleAuthProvider,
+ OAuthProvider,
+ signInWithPopup,
+ signOut,
+} from "firebase/auth";
+import { auth } from "./firebase";
// Single Responsibility: this function triggers a Google sign-in flow
export async function signInWithGoogle(): Promise {
@@ -7,7 +12,27 @@ export async function signInWithGoogle(): Promise {
await signInWithPopup(auth, provider);
}
+// Single Responsibility: this function triggers a Microsoft sign-in flow
+export async function signInWithMicrosoft(): Promise {
+ const provider = new OAuthProvider("microsoft.com");
+ // Configure Microsoft provider with necessary scopes
+ provider.addScope("user.read");
+ provider.addScope("openid");
+ provider.addScope("profile");
+ provider.addScope("email");
+
+ // Set tenant to 'common' to allow both work/school and personal Microsoft accounts
+ // Add additional parameters to ensure we get the photo URL
+ provider.setCustomParameters({
+ tenant: "common",
+ prompt: "select_account",
+ });
+
+ const result = await signInWithPopup(auth, provider);
+ console.log("MS Sign in success: user:", result.user);
+}
+
// Single Responsibility: sign out the current user
export async function signOutUser(): Promise {
await signOut(auth);
-}
\ No newline at end of file
+}
diff --git a/lib/domain/Decision.ts b/lib/domain/Decision.ts
index 3300491..64398ef 100644
--- a/lib/domain/Decision.ts
+++ b/lib/domain/Decision.ts
@@ -1,24 +1,56 @@
-import { Search, Settings, Lightbulb, Zap, BookOpen } from 'lucide-react'
+import { Search, Settings, Zap, BookOpen, Users } from 'lucide-react'
import { SupportingMaterial } from '@/lib/domain/SupportingMaterial'
import { IsArray, IsDate, IsEnum, IsOptional, IsString } from 'class-validator'
-import { DecisionStateError, StakeholderError } from '@/lib/domain/DecisionError'
-import { DecisionRelationship } from '@/lib/domain/DecisionRelationship'
-
-export const DecisionWorkflowSteps = [
- { icon: Search, label: 'Identify' },
- { icon: Settings, label: 'Method' },
- { icon: Lightbulb, label: 'Options' },
- { icon: Zap, label: 'Choose' },
- { icon: BookOpen, label: 'Publish' },
+import { StakeholderError, DecisionStateError } from '@/lib/domain/DecisionError'
+import { DocumentReference } from 'firebase/firestore'
+
+/**
+ * Represents a step in the decision workflow process.
+ * Each step has a unique key, an icon for visual representation,
+ * and a display label.
+ */
+export const DecisionWorkflowSteps = {
+ IDENTIFY: { key: 'identify', icon: Search, label: 'Identify' },
+ STAKEHOLDERS: { key: 'stakeholders', icon: Users, label: 'Stakeholders' },
+ METHOD: { key: 'method', icon: Settings, label: 'Method' },
+ CHOOSE: { key: 'choose', icon: Zap, label: 'Choose' },
+ PUBLISH: { key: 'publish', icon: BookOpen, label: 'Publish' },
+} as const;
+
+/**
+ * Defines the sequence of steps in the decision workflow.
+ * This order is used for navigation and display purposes.
+ */
+export const DecisionWorkflowStepsSequence = [
+ DecisionWorkflowSteps.IDENTIFY,
+ DecisionWorkflowSteps.STAKEHOLDERS,
+ DecisionWorkflowSteps.METHOD,
+ DecisionWorkflowSteps.CHOOSE,
+ DecisionWorkflowSteps.PUBLISH,
] as const;
-export type DecisionWorkflowStep = typeof DecisionWorkflowSteps[number];
+export type DecisionWorkflowStepKey = keyof typeof DecisionWorkflowSteps;
+export type DecisionWorkflowStep = typeof DecisionWorkflowSteps[DecisionWorkflowStepKey];
+
+/**
+ * Defines the role responsible for each step in the workflow
+ */
+export type StepRole = 'Driver' | 'Decider';
+export const StepRoles: Record = {
+ IDENTIFY: 'Driver',
+ STAKEHOLDERS: 'Driver',
+ METHOD: 'Driver',
+ CHOOSE: 'Decider',
+ PUBLISH: 'Decider',
+} as const;
export type DecisionStatus = "in_progress" | "blocked" | "published" | "superseded";
export type DecisionMethod = "accountable_individual" | "consent";
export type StakeholderRole = "decider" | "consulted" | "informed";
export type Cost = "low" | "medium" | "high";
export type Reversibility = "hat" | "haircut" | "tattoo";
+export type DecisionRelationshipType = "blocked_by" | "supersedes" | "blocks" | "superseded_by";
+
export interface Criterion {
id: string;
@@ -35,14 +67,42 @@ export type DecisionStakeholderRole = {
role: StakeholderRole;
};
+export interface DecisionRelationship {
+ targetDecision: DocumentReference;
+ targetDecisionTitle: string;
+ type: DecisionRelationshipType;
+}
+
+export class DecisionRelationshipTools {
+
+ static getTargetDecisionOrganisationId(decisionRelationship: DecisionRelationship): string {
+ const pathParts = decisionRelationship.targetDecision.path.split('/');
+ const orgIndex = pathParts.indexOf('organisations');
+ return orgIndex >= 0 ? pathParts[orgIndex + 1] : '';
+ }
+
+ static getInverseRelationshipType(type: DecisionRelationshipType): DecisionRelationshipType {
+ const lookupInverse: Record = {
+ 'supersedes': 'superseded_by',
+ 'blocked_by': 'blocks',
+ 'blocks': 'blocked_by',
+ 'superseded_by': 'supersedes'
+ }
+ return lookupInverse[type];
+ }
+
+}
+
+export type DecisionRelationshipMap = {
+ [key: string]: DecisionRelationship;
+};
+
export type DecisionProps = {
id: string;
title: string;
description: string;
cost: Cost;
createdAt: Date;
- criteria: string[];
- options: string[];
decision?: string;
decisionMethod?: string;
reversibility: Reversibility;
@@ -52,9 +112,10 @@ export type DecisionProps = {
driverStakeholderId: string;
supportingMaterials?: SupportingMaterial[];
organisationId: string;
- teamId: string;
- projectId: string;
- relationships?: DecisionRelationship[];
+ teamIds: string[];
+ projectIds: string[];
+ relationships?: DecisionRelationshipMap;
+ decisionNotes?: string;
};
export class Decision {
@@ -73,14 +134,6 @@ export class Decision {
@IsDate()
readonly createdAt: Date;
- @IsArray()
- @IsString({ each: true })
- readonly criteria: string[];
-
- @IsArray()
- @IsString({ each: true })
- readonly options: string[];
-
@IsOptional()
@IsString()
readonly decision?: string;
@@ -112,61 +165,74 @@ export class Decision {
@IsString()
readonly organisationId: string;
- @IsString()
- readonly teamId: string;
+ @IsArray()
+ @IsString({ each: true })
+ readonly teamIds: string[];
- @IsString()
- readonly projectId: string;
+ @IsArray()
+ @IsString({ each: true })
+ readonly projectIds: string[];
@IsOptional()
- @IsArray()
- readonly relationships?: DecisionRelationship[];
-
- // These relationship are captured in the UI and stored as DecisionRelationship objects
- get supersedes(): DecisionRelationship[] {
- return this.relationships?.filter(r =>
- r.type === 'supersedes' &&
- r.fromDecisionId === this.id
- ) ?? [];
+ readonly relationships?: DecisionRelationshipMap;
+
+ @IsOptional()
+ @IsString()
+ readonly decisionNotes?: string;
+
+ toDocumentReference(): DocumentReference {
+ return {
+ id: this.id,
+ path: `organisations/${this.organisationId}/decisions/${this.id}`
+ } as DocumentReference;
}
- get blockedBy(): DecisionRelationship[] {
- return this.relationships?.filter(r =>
- r.type === 'blocked_by' &&
- r.fromDecisionId === this.id
- ) ?? [];
+
+ private getRelationshipKey(type: DecisionRelationshipType, targetDecisionId: string): string {
+ return `${type}_${targetDecisionId}`;
}
- // These relationships are the inverse of the DecisionRelationship objects
- get blocks(): DecisionRelationship[] {
- const blockedByWithThisDecisionAsTheToDecision = this.relationships?.filter(r =>
- r.type === 'blocked_by' &&
- r.toDecisionId === this.id
- ) ?? [];
- // Invert the relationship to derive 'blocks' relationships
- return blockedByWithThisDecisionAsTheToDecision.map(r => DecisionRelationship.create({
- ...r,
- type: 'blocks',
- fromDecisionId: r.toDecisionId,
- toDecisionId: r.fromDecisionId,
- }));
+ getRelationshipsByType(type: DecisionRelationshipType): DecisionRelationship[] {
+ if (!this.relationships) return [];
+
+ return Object.entries(this.relationships)
+ .filter(([, relationship]) => relationship.type === type)
+ .map(([, relationship]) => relationship);
}
- get supersededBy(): DecisionRelationship[] {
- const supersedesWithThisDecisionAsTheToDecision = this.relationships?.filter(r =>
- r.type === 'supersedes' &&
- r.toDecisionId === this.id
- ) ?? [];
- // Invert the relationship to derive 'superseded_by' relationships
- return supersedesWithThisDecisionAsTheToDecision.map(r => DecisionRelationship.create({
- ...r,
- type: 'superseded_by',
- fromDecisionId: r.toDecisionId,
- toDecisionId: r.fromDecisionId,
- }));
+
+ setRelationship(type: DecisionRelationshipType, targetDecision: Decision): Decision {
+ const key = this.getRelationshipKey(type, targetDecision.id);
+ const newRelationship = {
+ targetDecision: targetDecision.toDocumentReference(),
+ targetDecisionTitle: targetDecision.title,
+ type
+ } as DecisionRelationship;
+
+ return this.with({
+ relationships: {
+ ...this.relationships,
+ [key]: newRelationship
+ }
+ });
+ }
+
+ unsetRelationship(type: DecisionRelationshipType, targetDecisionId: string): Decision {
+ const key = this.getRelationshipKey(type, targetDecisionId);
+
+ if (!this.relationships?.[key]) {
+ return this;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [key]: _, ...remainingRelationships } = this.relationships;
+
+ return this.with({
+ relationships: remainingRelationships
+ });
}
get status(): DecisionStatus {
// Check if superseded
- if (this.supersededBy.length > 0) {
+ if (this.getRelationshipsByType('superseded_by').length > 0) {
return 'superseded';
}
@@ -176,7 +242,7 @@ export class Decision {
}
// Check if blocked
- if (this.blockedBy.length > 0) {
+ if (this.getRelationshipsByType('blocked_by').length > 0) {
return 'blocked';
}
@@ -186,18 +252,18 @@ export class Decision {
get currentStep(): DecisionWorkflowStep {
if (this.status === 'published' || this.status === 'superseded') {
- return DecisionWorkflowSteps[4]; // Published step
+ return DecisionWorkflowSteps.PUBLISH;
}
if (this.decision) {
- return DecisionWorkflowSteps[3]; // Choose step
- }
- if (this.options.length > 0) {
- return DecisionWorkflowSteps[2]; // Options step
+ return DecisionWorkflowSteps.CHOOSE;
}
if (this.decisionMethod) {
- return DecisionWorkflowSteps[1]; // Method step
+ return DecisionWorkflowSteps.METHOD;
+ }
+ if (this.decisionStakeholderIds.length > 0) {
+ return DecisionWorkflowSteps.STAKEHOLDERS;
}
- return DecisionWorkflowSteps[0]; // Identify step (default)
+ return DecisionWorkflowSteps.IDENTIFY;
}
get decisionStakeholderIds(): string[] {
@@ -205,7 +271,45 @@ export class Decision {
}
isSuperseded(): boolean {
- return this.status === 'superseded' && this.supersededBy.length > 0;
+ return this.status === 'superseded' && this.getRelationshipsByType('superseded_by').length > 0;
+ }
+
+ isBlocked(): boolean {
+ return this.status === 'blocked' && this.getRelationshipsByType('blocked_by').length > 0;
+ }
+
+ isPublished(): boolean {
+ return this.publishDate !== undefined;
+ }
+
+ getSupersedesRelationship(): DecisionRelationship | undefined {
+ return this.getRelationshipsByType('supersedes')[0];
+ }
+
+ getSupersededByRelationship(): DecisionRelationship | undefined {
+ return this.getRelationshipsByType('superseded_by')[0];
+ }
+
+ publish(): Decision {
+ if (!this.decision) {
+ throw new DecisionStateError('Cannot publish a decision without a chosen option');
+ }
+
+ if (this.publishDate) {
+ throw new DecisionStateError('Decision is already published');
+ }
+
+ if (this.isBlocked()) {
+ throw new DecisionStateError('Cannot publish a blocked decision');
+ }
+
+ if (this.isSuperseded()) {
+ throw new DecisionStateError('Cannot publish a superseded decision');
+ }
+
+ return this.with({
+ publishDate: new Date()
+ });
}
addStakeholder(stakeholderId: string, role: StakeholderRole = "informed"): Decision {
@@ -235,12 +339,20 @@ export class Decision {
}
setDecisionDriver(driverStakeholderId: string): Decision {
- // First ensure the driver is a stakeholder
- const withDriver = this.addStakeholder(driverStakeholderId);
-
- return withDriver.with({
- driverStakeholderId
- });
+ // First ensure the new driver is a stakeholder and update the driverStakeholderId
+ const withNewDriver = (
+ this.stakeholders.some(s => s.stakeholder_id === driverStakeholderId)
+ ? this
+ : this.addStakeholder(driverStakeholderId)
+ ).with({ driverStakeholderId });
+
+ // Then remove the old driver from stakeholders list if they're not the new driver
+ const oldDriverId = this.driverStakeholderId;
+ return (oldDriverId && oldDriverId !== driverStakeholderId)
+ ? withNewDriver.with({
+ stakeholders: withNewDriver.stakeholders.filter(s => s.stakeholder_id !== oldDriverId)
+ })
+ : withNewDriver;
}
private constructor(props: DecisionProps) {
@@ -249,8 +361,6 @@ export class Decision {
this.description = props.description;
this.cost = props.cost;
this.createdAt = props.createdAt;
- this.criteria = props.criteria;
- this.options = props.options;
this.decision = props.decision;
this.decisionMethod = props.decisionMethod;
this.reversibility = props.reversibility;
@@ -260,9 +370,11 @@ export class Decision {
this.driverStakeholderId = props.driverStakeholderId;
this.supportingMaterials = props.supportingMaterials || [];
this.organisationId = props.organisationId;
- this.teamId = props.teamId;
- this.projectId = props.projectId;
- this.relationships = props.relationships || [];
+ this.teamIds = props.teamIds || [];
+ this.projectIds = props.projectIds || [];
+ this.relationships = props.relationships;
+ this.decisionNotes = props.decisionNotes;
+ this.validate();
}
static create(props: DecisionProps): Decision {
@@ -271,29 +383,21 @@ export class Decision {
static createEmptyDecision(defaultOverrides: Partial = {}): Decision {
const now = new Date();
- const defaults: DecisionProps = {
- id: 'unsaved',
+ return Decision.create({
+ id: Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
title: '',
description: '',
- cost: 'medium' as Cost,
+ cost: 'low',
createdAt: now,
- criteria: [],
- options: [],
- reversibility: 'hat' as Reversibility,
+ decision: undefined,
+ decisionMethod: undefined,
+ reversibility: 'hat',
stakeholders: [],
- updatedAt: now,
driverStakeholderId: '',
- decision: '',
- decisionMethod: '',
supportingMaterials: [],
organisationId: '',
- teamId: '',
- projectId: '',
- relationships: [],
- };
-
- return new Decision({
- ...defaults,
+ teamIds: [],
+ projectIds: [],
...defaultOverrides,
});
}
@@ -314,7 +418,71 @@ export class Decision {
withoutId(): Omit {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { id, ...propsWithoutId } = this;
- return propsWithoutId;
+ const { id, ...rest } = this;
+ return rest;
+ }
+
+ private validate(): void {
+ // Implementation of validation logic
+ }
+}
+
+/**
+ * Provides navigation and state management utilities for the decision workflow.
+ * Ensures consistent step transitions and state management across the application.
+ */
+export class WorkflowNavigator {
+ /**
+ * Gets the index of a step in the workflow sequence.
+ * @throws {Error} If the step is not found in the sequence
+ */
+ static getStepIndex(step: DecisionWorkflowStep): number {
+ const index = DecisionWorkflowStepsSequence.findIndex(s => s.key === step.key);
+ if (index === -1) {
+ throw new Error(`Invalid workflow step: ${step.key}`);
+ }
+ return index;
+ }
+
+ /**
+ * Gets the previous step in the workflow sequence.
+ * @returns The previous step or null if at the start
+ */
+ static getPreviousStep(currentStep: DecisionWorkflowStep): DecisionWorkflowStep | null {
+ const currentIndex = this.getStepIndex(currentStep);
+ return currentIndex > 0 ? DecisionWorkflowStepsSequence[currentIndex - 1] : null;
+ }
+
+ /**
+ * Gets the next step in the workflow sequence.
+ * @returns The next step or null if at the end
+ */
+ static getNextStep(currentStep: DecisionWorkflowStep): DecisionWorkflowStep | null {
+ const currentIndex = this.getStepIndex(currentStep);
+ return currentIndex < DecisionWorkflowStepsSequence.length - 1 ? DecisionWorkflowStepsSequence[currentIndex + 1] : null;
+ }
+
+ /**
+ * Checks if the given step is the first in the sequence
+ */
+ static isFirstStep(step: DecisionWorkflowStep): boolean {
+ return this.getStepIndex(step) === 0;
+ }
+
+ /**
+ * Checks if the given step is the last in the sequence
+ */
+ static isLastStep(step: DecisionWorkflowStep): boolean {
+ return this.getStepIndex(step) === DecisionWorkflowStepsSequence.length - 1;
+ }
+
+ /**
+ * Validates if a transition between steps is allowed.
+ * Only allows moving one step forward or backward.
+ */
+ static isValidTransition(from: DecisionWorkflowStep, to: DecisionWorkflowStep): boolean {
+ const fromIndex = this.getStepIndex(from);
+ const toIndex = this.getStepIndex(to);
+ return toIndex <= fromIndex + 1 && toIndex >= fromIndex - 1;
}
}
diff --git a/lib/domain/DecisionRelationship.ts b/lib/domain/DecisionRelationship.ts
deleted file mode 100644
index 5a64031..0000000
--- a/lib/domain/DecisionRelationship.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { IsDate, IsEnum, IsString } from 'class-validator'
-import { DecisionDependencyError } from '@/lib/domain/DecisionError'
-import { Decision } from '@/lib/domain/Decision'
-
-export type DecisionRelationshipType = "blocked_by" | "supersedes" | "blocks" | "superseded_by"
-
-export interface DecisionRelationshipProps {
- fromDecisionId: string
- toDecisionId: string
- type: DecisionRelationshipType
- createdAt: Date
- fromTeamId: string
- fromProjectId: string
- toTeamId: string
- toProjectId: string
- organisationId: string
-}
-
-export class DecisionRelationship {
- @IsString()
- readonly id: string
-
- @IsString()
- readonly fromDecisionId: string
-
- @IsString()
- readonly toDecisionId: string
-
- @IsEnum(['blocked_by', 'supersedes', 'blocks', 'superseded_by'])
- readonly type: DecisionRelationshipType
-
- @IsDate()
- readonly createdAt: Date
-
- @IsString()
- readonly fromTeamId: string
-
- @IsString()
- readonly fromProjectId: string
-
- @IsString()
- readonly toTeamId: string
-
- @IsString()
- readonly toProjectId: string
-
- @IsString()
- readonly organisationId: string
-
- private constructor(props: DecisionRelationshipProps) {
- this.id = `${props.fromDecisionId}_${props.toDecisionId}`
- this.fromDecisionId = props.fromDecisionId
- this.toDecisionId = props.toDecisionId
- this.type = props.type
- this.createdAt = props.createdAt
- this.fromTeamId = props.fromTeamId
- this.fromProjectId = props.fromProjectId
- this.toTeamId = props.toTeamId
- this.toProjectId = props.toProjectId
- this.organisationId = props.organisationId
- }
-
- static create(props: DecisionRelationshipProps): DecisionRelationship {
- return new DecisionRelationship(props)
- }
-
- static createBlockedByRelationship(
- fromDecision: Decision,
- toDecision: Decision
- ): DecisionRelationship {
- if (fromDecision.id === toDecision.id) {
- throw new DecisionDependencyError('Decision cannot block itself')
- }
-
- if (fromDecision.organisationId !== toDecision.organisationId) {
- throw new DecisionDependencyError('Decisions must belong to the same organisation')
- }
-
- return DecisionRelationship.create({
- fromDecisionId: fromDecision.id,
- fromTeamId: fromDecision.teamId,
- fromProjectId: fromDecision.projectId,
- type: 'blocked_by',
- toDecisionId: toDecision.id,
- toTeamId: toDecision.teamId,
- toProjectId: toDecision.projectId,
- createdAt: new Date(),
- organisationId: fromDecision.organisationId
- })
- }
-
- static createSupersedesRelationship(
- fromDecision: Decision,
- toDecision: Decision
- ): DecisionRelationship {
- if (fromDecision.id === toDecision.id) {
- throw new DecisionDependencyError('Decision cannot supersede itself')
- }
-
- if (fromDecision.organisationId !== toDecision.organisationId) {
- throw new DecisionDependencyError('Decisions must belong to the same organisation')
- }
-
- return DecisionRelationship.create({
- fromDecisionId: fromDecision.id,
- fromTeamId: fromDecision.teamId,
- fromProjectId: fromDecision.projectId,
- type: 'supersedes',
- toDecisionId: toDecision.id,
- toTeamId: toDecision.teamId,
- toProjectId: toDecision.projectId,
- createdAt: new Date(),
- organisationId: fromDecision.organisationId
- })
- }
-}
\ No newline at end of file
diff --git a/lib/domain/Organisation.ts b/lib/domain/Organisation.ts
index b5bbf4e..80ef3fa 100644
--- a/lib/domain/Organisation.ts
+++ b/lib/domain/Organisation.ts
@@ -7,7 +7,7 @@ import { DomainValidationError } from '@/lib/domain/DomainValidationError'
export interface OrganisationProps {
id: string
name: string
- teams: Team[]
+ teams?: Team[]
}
export class Organisation {
@@ -25,7 +25,7 @@ export class Organisation {
private constructor(props: OrganisationProps) {
this.id = props.id
this.name = props.name
- this.teams = props.teams.map(t => Team.create(t))
+ this.teams = props.teams?.map(t => Team.create(t)) || []
this.validate()
}
diff --git a/lib/domain/Project.ts b/lib/domain/Project.ts
index 42ca107..e0acd11 100644
--- a/lib/domain/Project.ts
+++ b/lib/domain/Project.ts
@@ -1,14 +1,11 @@
import { IsString, MinLength, validateSync } from 'class-validator'
-import { Type } from 'class-transformer'
-import { Decision } from '@/lib/domain/Decision'
import { DomainValidationError } from '@/lib/domain/DomainValidationError'
export interface ProjectProps {
id: string
name: string
description: string
- teamId: string
- decisions: Decision[]
+ organisationId: string
}
export class Project {
@@ -23,18 +20,13 @@ export class Project {
readonly description: string
@IsString()
- readonly teamId: string
-
- // @ValidateNested({ each: true })
- @Type(() => Decision)
- readonly decisions: Decision[]
+ readonly organisationId: string
private constructor(props: ProjectProps) {
this.id = props.id
this.name = props.name
this.description = props.description
- this.teamId = props.teamId
- this.decisions = props.decisions.map(d => Decision.create(d))
+ this.organisationId = props.organisationId
this.validate()
}
@@ -48,8 +40,4 @@ export class Project {
static create(props: ProjectProps): Project {
return new Project(props)
}
-
- findDecision(decisionId: string): Decision | undefined {
- return this.decisions.find(decision => decision.id === decisionId)
- }
}
\ No newline at end of file
diff --git a/lib/domain/TeamHierarchy.ts b/lib/domain/TeamHierarchy.ts
new file mode 100644
index 0000000..c4eb1a5
--- /dev/null
+++ b/lib/domain/TeamHierarchy.ts
@@ -0,0 +1,196 @@
+export interface TeamHierarchyNode {
+ id: string
+ name: string
+ parentId: string | null
+ children: { [key: string]: TeamHierarchyNode }
+}
+
+export interface TeamHierarchyProps {
+ teams: { [key: string]: TeamHierarchyNode }
+}
+
+export class TeamHierarchy {
+ readonly teams: { [key: string]: TeamHierarchyNode }
+
+ private constructor(props: TeamHierarchyProps) {
+ this.teams = props.teams
+ }
+
+ static create(props: TeamHierarchyProps): TeamHierarchy {
+ return new TeamHierarchy(props)
+ }
+
+ addTeam(team: TeamHierarchyNode): TeamHierarchy {
+ const newTeams = { ...this.teams }
+
+ // Add the new team
+ newTeams[team.id] = {
+ ...team,
+ children: team.children || {}
+ }
+
+ // Update parent's children if it exists
+ if (team.parentId && newTeams[team.parentId]) {
+ newTeams[team.parentId] = {
+ ...newTeams[team.parentId],
+ children: {
+ ...newTeams[team.parentId].children,
+ [team.id]: newTeams[team.id]
+ }
+ }
+ }
+
+ return TeamHierarchy.create({ teams: newTeams })
+ }
+
+ updateTeam(teamId: string, updates: Partial): TeamHierarchy {
+ if (!this.teams[teamId]) {
+ throw new Error(`Team with id ${teamId} not found`)
+ }
+
+ const newTeams = { ...this.teams }
+ newTeams[teamId] = {
+ ...newTeams[teamId],
+ ...updates,
+ children: newTeams[teamId].children // Preserve children
+ }
+
+ return TeamHierarchy.create({ teams: newTeams })
+ }
+
+ removeTeam(teamId: string): TeamHierarchy {
+ if (!this.teams[teamId]) {
+ throw new Error(`Team with id ${teamId} not found`)
+ }
+
+ const newTeams = { ...this.teams }
+
+ // Remove team from parent's children
+ const team = newTeams[teamId]
+ if (team.parentId && newTeams[team.parentId]) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [teamId]: _, ...remainingChildren } = newTeams[team.parentId].children
+ newTeams[team.parentId] = {
+ ...newTeams[team.parentId],
+ children: remainingChildren
+ }
+ }
+
+ // Remove team and all its descendants
+ const removeTeamAndDescendants = (id: string) => {
+ const team = newTeams[id]
+ if (!team) return
+
+ // Recursively remove all children
+ Object.keys(team.children || {}).forEach(childId => {
+ removeTeamAndDescendants(childId)
+ })
+
+ // Remove the team itself
+ delete newTeams[id]
+ }
+
+ removeTeamAndDescendants(teamId)
+
+ return TeamHierarchy.create({ teams: newTeams })
+ }
+
+ moveTeam(teamId: string, newParentId: string | null): TeamHierarchy {
+ if (!this.teams[teamId]) {
+ throw new Error(`Team with id ${teamId} not found`)
+ }
+
+ if (newParentId && !this.teams[newParentId]) {
+ throw new Error(`Parent team with id ${newParentId} not found`)
+ }
+
+ // Check for circular reference
+ let currentId: string | null = newParentId;
+ while (currentId) {
+ if (currentId === teamId) {
+ throw new Error('Cannot move team: would create circular reference');
+ }
+ currentId = this.teams[currentId].parentId;
+ }
+
+ const newTeams = { ...this.teams }
+ const team = newTeams[teamId]
+ const oldParentId = team.parentId
+
+ // Remove from old parent's children
+ if (oldParentId && newTeams[oldParentId]) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [teamId]: _, ...remainingChildren } = newTeams[oldParentId].children
+ newTeams[oldParentId] = {
+ ...newTeams[oldParentId],
+ children: remainingChildren
+ }
+ }
+
+ // Update team's parent
+ newTeams[teamId] = {
+ ...team,
+ parentId: newParentId
+ }
+
+ // Add to new parent's children
+ if (newParentId) {
+ newTeams[newParentId] = {
+ ...newTeams[newParentId],
+ children: {
+ ...newTeams[newParentId].children,
+ [teamId]: newTeams[teamId]
+ }
+ }
+ }
+
+ return TeamHierarchy.create({ teams: newTeams })
+ }
+
+ getTeam(teamId: string): TeamHierarchyNode | undefined {
+ return this.teams[teamId]
+ }
+
+ getRootTeams(): TeamHierarchyNode[] {
+ return Object.values(this.teams).filter(team => !team.parentId)
+ }
+
+ getChildren(teamId: string): TeamHierarchyNode[] {
+ const team = this.teams[teamId]
+ if (!team) return []
+ return Object.values(team.children || {})
+ }
+
+ getDescendants(teamId: string): TeamHierarchyNode[] {
+ const descendants: TeamHierarchyNode[] = []
+ const team = this.teams[teamId]
+ if (!team) return descendants
+
+ const addDescendants = (node: TeamHierarchyNode) => {
+ Object.values(node.children || {}).forEach(child => {
+ descendants.push(child)
+ addDescendants(child)
+ })
+ }
+
+ addDescendants(team)
+ return descendants
+ }
+
+ getAncestors(teamId: string): TeamHierarchyNode[] {
+ const ancestors: TeamHierarchyNode[] = []
+ let current = this.teams[teamId]
+
+ while (current?.parentId) {
+ const parent = this.teams[current.parentId]
+ if (parent) {
+ ancestors.push(parent)
+ current = parent
+ } else {
+ break
+ }
+ }
+
+ return ancestors
+ }
+}
\ No newline at end of file
diff --git a/lib/domain/TeamHierarchyRepository.ts b/lib/domain/TeamHierarchyRepository.ts
new file mode 100644
index 0000000..d37c68e
--- /dev/null
+++ b/lib/domain/TeamHierarchyRepository.ts
@@ -0,0 +1,17 @@
+import { TeamHierarchy } from '@/lib/domain/TeamHierarchy'
+
+export interface TeamHierarchyRepository {
+ /**
+ * Get the team hierarchy for an organisation
+ * @param organisationId The ID of the organisation
+ * @returns The team hierarchy or null if not found
+ */
+ getByOrganisationId(organisationId: string): Promise
+
+ /**
+ * Save a team hierarchy for an organisation
+ * @param organisationId The ID of the organisation
+ * @param hierarchy The team hierarchy to save
+ */
+ save(organisationId: string, hierarchy: TeamHierarchy): Promise
+}
\ No newline at end of file
diff --git a/lib/domain/__tests__/Decision.test.ts b/lib/domain/__tests__/Decision.test.ts
index 54fb5ce..b197d9f 100644
--- a/lib/domain/__tests__/Decision.test.ts
+++ b/lib/domain/__tests__/Decision.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll } from 'vitest'
-import { Decision, DecisionProps } from '@/lib/domain/Decision'
+import { Decision, DecisionProps, DecisionRelationship, DecisionRelationshipTools } from '@/lib/domain/Decision'
import { DecisionStateError, StakeholderError } from '@/lib/domain/DecisionError'
-import { DecisionRelationship } from '@/lib/domain/DecisionRelationship'
+import { DocumentReference } from 'firebase/firestore'
describe('Decision Domain Model', () => {
const defaultProps: DecisionProps = {
@@ -16,18 +16,18 @@ describe('Decision Domain Model', () => {
stakeholders: [],
driverStakeholderId: 'driver-1',
organisationId: 'org-1',
- teamId: 'team-1',
- projectId: 'project-1',
+ teamIds: ['team-1'],
+ projectIds: ['project-1'],
supportingMaterials: [],
- relationships: []
+ relationships: {}
}
describe('Project Ownership', () => {
- it('should always have organisationId, teamId, and projectId', () => {
+ it('should always have organisationId, teamIds, and projectIds', () => {
const decision = Decision.create(defaultProps)
expect(decision.organisationId).toBe('org-1')
- expect(decision.teamId).toBe('team-1')
- expect(decision.projectId).toBe('project-1')
+ expect(decision.teamIds).toContain('team-1')
+ expect(decision.projectIds).toContain('project-1')
})
})
@@ -71,16 +71,9 @@ describe('Decision Domain Model', () => {
const baseDecision = Decision.create({...defaultProps, id: 'base-decision'});
expect(baseDecision.status).toBe('in_progress');
- const blockingDecision = Decision.create({...defaultProps, id: 'blocking-decision'});
+ const blockingDecision = Decision.create({...defaultProps, id: 'blocking-decision', title: 'Blocking Decision'});
// Once a decision has a blockedBy relationship, it becomes blocked
- const blockedDecision = baseDecision.with({
- relationships: [
- DecisionRelationship.createBlockedByRelationship(
- baseDecision,
- blockingDecision
- )
- ]
- });
+ const blockedDecision = baseDecision.setRelationship('blocked_by', blockingDecision);
expect(blockedDecision.status).toBe('blocked');
// Once a decision gets a publishDate, it becomes published
@@ -90,15 +83,8 @@ describe('Decision Domain Model', () => {
expect(publishedDecision.status).toBe('published');
// Once a decision has a supersededBy relationship, it becomes superseded
- const supercedingDecision = Decision.create({...defaultProps, id: 'superceding-decision'});
- const supersededDecision = publishedDecision.with({
- relationships: [
- DecisionRelationship.createSupersedesRelationship(
- supercedingDecision,
- publishedDecision,
- )
- ]
- });
+ const supercedingDecision = Decision.create({...defaultProps, id: 'superceding-decision', title: 'Superceding Decision'});
+ const supersededDecision = publishedDecision.setRelationship('superseded_by', supercedingDecision);
expect(supersededDecision.status).toBe('superseded');
})
@@ -122,10 +108,7 @@ describe('Decision Domain Model', () => {
const withMethod = decision.with({ decisionMethod: 'consent' })
expect(withMethod.currentStep.label).toBe('Method')
- const withOptions = withMethod.with({ options: ['option1'] })
- expect(withOptions.currentStep.label).toBe('Options')
-
- const withDecision = withOptions.with({ decision: 'Final decision' })
+ const withDecision = withMethod.with({ decision: 'Final decision' })
expect(withDecision.currentStep.label).toBe('Choose')
const published = withDecision.with({ publishDate: new Date() })
@@ -138,68 +121,83 @@ describe('Decision Domain Model', () => {
let decisionA1: Decision
let decisionB: Decision
let decisionC: Decision
- let decisionA1SupersedesA: DecisionRelationship
- let decisionCIsBlockedByB: DecisionRelationship
beforeAll(() => {
decisionA = Decision.create({...defaultProps, id: 'decision-a', title: 'Decision A'})
decisionA1 = Decision.create({...defaultProps, id: 'decision-a1', title: 'Decision A1'})
decisionB = Decision.create({...defaultProps, id: 'decision-b', title: 'Decision B'})
decisionC = Decision.create({...defaultProps, id: 'decision-c', title: 'Decision C'})
- decisionA1SupersedesA = DecisionRelationship.createSupersedesRelationship(
- decisionA1,
- decisionA
- )
- // Decision A1 has 1 relationship as the fromDecision:
- // - (from) A1 supersedes (to) A
- decisionA1 = decisionA1.with({
- relationships: [decisionA1SupersedesA]
- })
- // Decision A gets the same relationship because its the toDecision
- // - (from) A1 supersedes (to) A - gets inverted to: (from) A superceeded_by (to) A1
- decisionA = decisionA.with({
- relationships: [decisionA1SupersedesA]
- })
-
- decisionCIsBlockedByB = DecisionRelationship.createBlockedByRelationship(
- decisionC,
- decisionB
- )
- // Decision C has 1 relationship as the fromDecision:
- // - (from) C is blocked_by (to) B
- decisionC = decisionC.with({
- relationships: [decisionCIsBlockedByB]
- })
- // Decision B gets the same relationship because its the toDecision
- // - (from) C is blocked_by (to) B - gets inverted to: (from) B blocks (to) C
- decisionB = decisionB.with({
- relationships: [decisionCIsBlockedByB]
- })
})
- it('should show that A1 supersedes A', () => {
- expect(decisionA1.supersedes.length).toBe(1)
- expect(decisionA1.supersedes[0].fromDecisionId).toBe(decisionA1.id)
- expect(decisionA1.supersedes[0].toDecisionId).toBe(decisionA.id)
- })
- it('(inverted) should show that A is superseded by A1', () => {
- expect(decisionA.supersededBy.length).toBe(1)
- expect(decisionA.supersededBy[0].fromDecisionId).toBe(decisionA.id)
- expect(decisionA.supersededBy[0].toDecisionId).toBe(decisionA1.id)
+ it('should manage supersedes relationships correctly', () => {
+ // A1 supersedes A
+ const updatedA1 = decisionA1.setRelationship('supersedes', decisionA);
+ const relationships = updatedA1.getRelationshipsByType('supersedes');
+
+ expect(relationships).toHaveLength(1);
+ expect(relationships[0].type).toBe('supersedes');
+ expect(relationships[0].targetDecision.id).toBe('decision-a');
+ expect(relationships[0].targetDecisionTitle).toBe('Decision A');
})
- it('show A as superceeded', () => {
- expect(decisionA.isSuperseded()).toBe(true)
+
+ it('should manage blocked_by relationships correctly', () => {
+ // C is blocked by B
+ const updatedC = decisionC.setRelationship('blocked_by', decisionB);
+ const relationships = updatedC.getRelationshipsByType('blocked_by');
+
+ expect(relationships).toHaveLength(1);
+ expect(relationships[0].type).toBe('blocked_by');
+ expect(relationships[0].targetDecision.id).toBe('decision-b');
+ expect(relationships[0].targetDecisionTitle).toBe('Decision B');
})
- it('should show that C is blocked_by B', () => {
- expect(decisionC.blockedBy.length).toBe(1)
- expect(decisionC.blockedBy[0].fromDecisionId).toBe(decisionC.id)
- expect(decisionC.blockedBy[0].toDecisionId).toBe(decisionB.id)
+ it('should remove relationships correctly', () => {
+ // Add and then remove a relationship
+ const withRelationship = decisionA.setRelationship('blocked_by', decisionB);
+ expect(withRelationship.getRelationshipsByType('blocked_by')).toHaveLength(1);
+
+ const withoutRelationship = withRelationship.unsetRelationship('blocked_by', decisionB.id);
+ expect(withoutRelationship.getRelationshipsByType('blocked_by')).toHaveLength(0);
})
- it('(inverted) should show that B blocks C', () => {
- expect(decisionB.blocks.length).toBe(1)
- expect(decisionB.blocks[0].fromDecisionId).toBe(decisionB.id)
- expect(decisionB.blocks[0].toDecisionId).toBe(decisionC.id)
+
+ it('should handle multiple relationships of the same type', () => {
+ // C is blocked by both A and B
+ const blockedByA = decisionC.setRelationship('blocked_by', decisionA);
+ const blockedByBoth = blockedByA.setRelationship('blocked_by', decisionB);
+
+ const relationships = blockedByBoth.getRelationshipsByType('blocked_by');
+ expect(relationships).toHaveLength(2);
+ expect(relationships.map(r => r.targetDecision.id).sort()).toEqual(['decision-a', 'decision-b'].sort());
})
+
+ it('should extract organisation ID from relationship target path', () => {
+ const mockDocRef = {
+ id: 'decision-x',
+ path: 'organisations/org-123/decisions/decision-x'
+ } as DocumentReference;
+
+ const relationship = {
+ targetDecision: mockDocRef,
+ targetDecisionTitle: 'Test Decision',
+ type: 'blocks'
+ } as DecisionRelationship;
+
+ expect(DecisionRelationshipTools.getTargetDecisionOrganisationId(relationship)).toBe('org-123');
+ });
+
+ it('should handle missing segments in relationship target path', () => {
+ const mockDocRef = {
+ id: 'decision-x',
+ path: 'some/invalid/path'
+ } as DocumentReference;
+
+ const relationship = {
+ targetDecision: mockDocRef,
+ targetDecisionTitle: 'Test Decision',
+ type: 'blocks'
+ } as DecisionRelationship;
+
+ expect(DecisionRelationshipTools.getTargetDecisionOrganisationId(relationship)).toBe('');
+ });
})
})
\ No newline at end of file
diff --git a/lib/domain/__tests__/DecisionPublishing.test.ts b/lib/domain/__tests__/DecisionPublishing.test.ts
new file mode 100644
index 0000000..f16eb60
--- /dev/null
+++ b/lib/domain/__tests__/DecisionPublishing.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from 'vitest'
+import { Decision, DecisionProps } from '@/lib/domain/Decision'
+import { DecisionStateError } from '@/lib/domain/DecisionError'
+
+describe('Decision Publishing', () => {
+ const defaultProps: DecisionProps = {
+ id: 'test-id',
+ title: 'Test Decision',
+ description: 'Test Description',
+ cost: 'low',
+ createdAt: new Date(),
+ criteria: ['test-criteria'],
+ options: ['option-a', 'option-b'],
+ reversibility: 'hat',
+ stakeholders: [
+ { stakeholder_id: 'decider-1', role: 'decider' }
+ ],
+ driverStakeholderId: 'decider-1',
+ organisationId: 'org-1',
+ teamIds: ['team-1'],
+ projectIds: ['project-1'],
+ supportingMaterials: [],
+ relationships: {}
+ }
+
+ describe('publish', () => {
+ it('should successfully publish a valid decision', () => {
+ const decision = Decision.create({
+ ...defaultProps,
+ decision: 'option-a',
+ decisionMethod: 'consent'
+ })
+
+ const publishedDecision = decision.publish()
+
+ expect(publishedDecision.status).toBe('published')
+ expect(publishedDecision.publishDate).toBeDefined()
+ expect(publishedDecision.currentStep.key).toBe('publish')
+ })
+
+ it('should set publishDate to current timestamp when publishing', () => {
+ const now = new Date()
+ const decision = Decision.create({
+ ...defaultProps,
+ decision: 'option-a',
+ decisionMethod: 'consent'
+ })
+
+ const publishedDecision = decision.publish()
+
+ expect(publishedDecision.publishDate).toBeInstanceOf(Date)
+ expect(publishedDecision.publishDate!.getTime()).toBeGreaterThanOrEqual(now.getTime())
+ expect(publishedDecision.publishDate!.getTime()).toBeLessThanOrEqual(new Date().getTime())
+ })
+
+ it('should throw error when publishing a decision without a chosen option', () => {
+ const decision = Decision.create({
+ ...defaultProps,
+ decisionMethod: 'consent'
+ })
+
+ expect(() => decision.publish()).toThrow(DecisionStateError)
+ })
+
+ it('should throw error when publishing an already published decision', () => {
+ const decision = Decision.create({
+ ...defaultProps,
+ decision: 'option-a',
+ decisionMethod: 'consent',
+ publishDate: new Date()
+ })
+
+ expect(() => decision.publish()).toThrow(DecisionStateError)
+ })
+
+ it('should throw error when publishing a blocked decision', () => {
+ const blockingDecision = Decision.create({
+ ...defaultProps,
+ id: 'blocking-decision',
+ title: 'Blocking Decision'
+ })
+
+ const decision = Decision.create({
+ ...defaultProps,
+ decision: 'option-a',
+ decisionMethod: 'consent'
+ }).setRelationship('blocked_by', blockingDecision)
+
+ expect(() => decision.publish()).toThrow(DecisionStateError)
+ })
+
+ it('should throw error when publishing a superseded decision', () => {
+ const supersedingDecision = Decision.create({
+ ...defaultProps,
+ id: 'superseding-decision',
+ title: 'Superseding Decision'
+ })
+
+ const decision = Decision.create({
+ ...defaultProps,
+ decision: 'option-a',
+ decisionMethod: 'consent'
+ }).setRelationship('superseded_by', supersedingDecision)
+
+ expect(() => decision.publish()).toThrow(DecisionStateError)
+ })
+ })
+})
\ No newline at end of file
diff --git a/lib/domain/__tests__/DecisionRelationship.test.ts b/lib/domain/__tests__/DecisionRelationship.test.ts
deleted file mode 100644
index 05bd61a..0000000
--- a/lib/domain/__tests__/DecisionRelationship.test.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { Decision } from '@/lib/domain/Decision'
-import { DecisionRelationship } from '@/lib/domain/DecisionRelationship'
-import { DecisionDependencyError } from '@/lib/domain/DecisionError'
-
-describe('DecisionRelationship Domain Model', () => {
- const emptyDecision = Decision.createEmptyDecision({
- id: 'empty-decision-1',
- teamId: 'team-1',
- projectId: 'project-1',
- organisationId: 'org-1'
- });
-
- describe('Creating Blocking Relationships', () => {
- it('should create a blocking relationship between two decisions', () => {
- const blockingDecision = emptyDecision.with({
- id: 'blocking-decision'
- })
- const blockedDecision = emptyDecision.with({
- id: 'blocked-decision'
- })
-
- const relationship = DecisionRelationship.createBlockedByRelationship(
- blockedDecision,
- blockingDecision,
- )
-
- expect(relationship.organisationId).toBe('org-1')
- expect(relationship.fromDecisionId).toBe('blocked-decision')
- expect(relationship.fromTeamId).toBe('team-1')
- expect(relationship.fromProjectId).toBe('project-1')
- expect(relationship.type).toBe('blocked_by')
- expect(relationship.toDecisionId).toBe('blocking-decision')
- expect(relationship.toTeamId).toBe('team-1')
- expect(relationship.toProjectId).toBe('project-1')
- })
-
- it('should throw error when attempting self-blocking', () => {
- const decision1 = emptyDecision.with({id: "decision-1"})
- expect(() => {
- DecisionRelationship.createBlockedByRelationship(decision1, decision1)
- }).toThrow(DecisionDependencyError)
- })
-
- it('should throw error when decisions are from different organisations', () => {
- const blockingDecision = emptyDecision.with({
- id: 'blocking-decision',
- organisationId: 'org-1'
- })
- const blockedDecision = emptyDecision.with({
- id: 'blocked-decision',
- organisationId: 'org-2'
- })
-
- expect(() => {
- DecisionRelationship.createBlockedByRelationship(blockingDecision, blockedDecision)
- }).toThrow(DecisionDependencyError)
- })
- })
-
- describe('Creating Superseding Relationships', () => {
- it('should create a superseding relationship between two decisions', () => {
- const supersedingDecision = emptyDecision.with({
- id: 'superseding-decision'
- })
- const supersededDecision = emptyDecision.with({
- id: 'superseded-decision'
- })
-
- const relationship = DecisionRelationship.createSupersedesRelationship(
- supersedingDecision,
- supersededDecision
- )
-
- expect(relationship.organisationId).toBe('org-1')
- expect(relationship.fromDecisionId).toBe('superseding-decision')
- expect(relationship.fromTeamId).toBe('team-1')
- expect(relationship.fromProjectId).toBe('project-1')
- expect(relationship.type).toBe('supersedes')
- expect(relationship.toDecisionId).toBe('superseded-decision')
- expect(relationship.toTeamId).toBe('team-1')
- expect(relationship.toProjectId).toBe('project-1')
- })
-
- it('should throw error when attempting self-supersession', () => {
- const decision1 = emptyDecision.with({id: "decision-1"})
-
- expect(() => {
- DecisionRelationship.createSupersedesRelationship(decision1, decision1)
- }).toThrow(DecisionDependencyError)
- })
-
- it('should throw error when decisions are from different organisations', () => {
- const supersedingDecision = emptyDecision.with({
- id: 'superseding-decision',
- organisationId: 'org-1'
- })
- const supersededDecision = emptyDecision.with({
- id: 'superseded-decision',
- organisationId: 'org-2'
- })
-
- expect(() => {
- DecisionRelationship.createSupersedesRelationship(supersedingDecision, supersededDecision)
- }).toThrow(DecisionDependencyError)
- })
- })
-
- describe('Relationship ID Generation', () => {
- it('should generate consistent IDs for relationships', () => {
- const decision1 = emptyDecision.with({
- id: 'decision-1'
- })
- const decision1_1 = emptyDecision.with({
- id: 'decision-1.1'
- })
- const decision2 = emptyDecision.with({
- id: 'decision-2'
- })
- const superceededRelationship = DecisionRelationship.createSupersedesRelationship(decision1_1, decision1);
- const blockedRelationship = DecisionRelationship.createBlockedByRelationship(decision2, decision1_1);
-
- expect(superceededRelationship.id).toBe('decision-1.1_decision-1')
- expect(blockedRelationship.id).toBe('decision-2_decision-1.1')
- })
- })
-})
\ No newline at end of file
diff --git a/lib/domain/__tests__/TeamHierarchy.test.ts b/lib/domain/__tests__/TeamHierarchy.test.ts
new file mode 100644
index 0000000..afd2666
--- /dev/null
+++ b/lib/domain/__tests__/TeamHierarchy.test.ts
@@ -0,0 +1,379 @@
+import { describe, it, expect } from 'vitest'
+import { TeamHierarchy, TeamHierarchyNode } from '@/lib/domain/TeamHierarchy'
+
+describe('TeamHierarchy', () => {
+ describe('create', () => {
+ it('should create a valid team hierarchy', () => {
+ // Arrange
+ const teams: Record = {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ },
+ 'team-3': {
+ id: 'team-3',
+ name: 'Product',
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+ },
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ },
+ 'team-3': {
+ id: 'team-3',
+ name: 'Product',
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+
+ // Act
+ const hierarchy = TeamHierarchy.create({ teams })
+
+ // Assert
+ expect(hierarchy).toBeDefined()
+ expect(hierarchy.teams).toEqual(teams)
+ })
+
+ it('should validate team node structure', () => {
+ // Arrange
+ const invalidTeams = {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {
+ 'team-2': {
+ id: 'team-2',
+ // Missing name property
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+ }
+ }
+
+ // Act & Assert
+ expect(() => TeamHierarchy.create({ teams: invalidTeams })).toThrow()
+ })
+
+ it('should validate parent-child relationships', () => {
+ // Arrange
+ const invalidTeams = {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'non-existent-team', // Invalid parent reference
+ children: {}
+ }
+ }
+ },
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'non-existent-team', // Invalid parent reference
+ children: {}
+ }
+ }
+
+ // Act & Assert
+ expect(() => TeamHierarchy.create({ teams: invalidTeams })).toThrow()
+ })
+ })
+
+ describe('addTeam', () => {
+ it('should add a team as a root node when parentId is null', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({ teams: {} })
+ const newTeam = {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null
+ }
+
+ // Act
+ const updatedHierarchy = hierarchy.addTeam(newTeam)
+
+ // Assert
+ expect(updatedHierarchy.teams['team-1']).toBeDefined()
+ expect(updatedHierarchy.teams['team-1'].name).toBe('Leadership Team')
+ expect(updatedHierarchy.teams['team-1'].parentId).toBeNull()
+ expect(updatedHierarchy.teams['team-1'].children).toEqual({})
+ })
+
+ it('should add a team as a child node when parentId is provided', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {}
+ }
+ }
+ })
+ const newTeam = {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1'
+ }
+
+ // Act
+ const updatedHierarchy = hierarchy.addTeam(newTeam)
+
+ // Assert
+ expect(updatedHierarchy.teams['team-1'].children['team-2']).toBeDefined()
+ expect(updatedHierarchy.teams['team-1'].children['team-2'].name).toBe('Engineering')
+ expect(updatedHierarchy.teams['team-1'].children['team-2'].parentId).toBe('team-1')
+ expect(updatedHierarchy.teams['team-1'].children['team-2'].children).toEqual({})
+ expect(updatedHierarchy.teams['team-2']).toBeDefined()
+ })
+
+ it('should throw an error when adding a team with a non-existent parent', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({ teams: {} })
+ const newTeam = {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'non-existent-team'
+ }
+
+ // Act & Assert
+ expect(() => hierarchy.addTeam(newTeam)).toThrow()
+ })
+ })
+
+ describe('updateTeam', () => {
+ it('should update a team\'s properties', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {}
+ }
+ }
+ })
+ const updatedTeamData = {
+ id: 'team-1',
+ name: 'Executive Leadership',
+ parentId: null
+ }
+
+ // Act
+ const updatedHierarchy = hierarchy.updateTeam(updatedTeamData)
+
+ // Assert
+ expect(updatedHierarchy.teams['team-1'].name).toBe('Executive Leadership')
+ })
+
+ it('should throw an error when updating a non-existent team', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({ teams: {} })
+ const updatedTeamData = {
+ id: 'non-existent-team',
+ name: 'Some Team',
+ parentId: null
+ }
+
+ // Act & Assert
+ expect(() => hierarchy.updateTeam(updatedTeamData)).toThrow()
+ })
+ })
+
+ describe('moveTeam', () => {
+ it('should move a team to a new parent', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+ },
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ },
+ 'team-3': {
+ id: 'team-3',
+ name: 'Product',
+ parentId: null,
+ children: {}
+ }
+ }
+ })
+
+ // Act
+ const updatedHierarchy = hierarchy.moveTeam('team-2', 'team-3')
+
+ // Assert
+ expect(updatedHierarchy.teams['team-1'].children['team-2']).toBeUndefined()
+ expect(updatedHierarchy.teams['team-3'].children['team-2']).toBeDefined()
+ expect(updatedHierarchy.teams['team-2'].parentId).toBe('team-3')
+ })
+
+ it('should throw an error when moving a non-existent team', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {}
+ }
+ }
+ })
+
+ // Act & Assert
+ expect(() => hierarchy.moveTeam('non-existent-team', 'team-1')).toThrow()
+ })
+
+ it('should throw an error when moving to a non-existent parent', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+ },
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+ })
+
+ // Act & Assert
+ expect(() => hierarchy.moveTeam('team-2', 'non-existent-team')).toThrow()
+ })
+ })
+
+ describe('removeTeam', () => {
+ it('should remove a team with no children', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+ },
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {}
+ }
+ }
+ })
+
+ // Act
+ const updatedHierarchy = hierarchy.removeTeam('team-2')
+
+ // Assert
+ expect(updatedHierarchy.teams['team-2']).toBeUndefined()
+ expect(updatedHierarchy.teams['team-1'].children['team-2']).toBeUndefined()
+ })
+
+ it('should throw an error when removing a team with children', () => {
+ // Arrange
+ const hierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {
+ 'team-3': {
+ id: 'team-3',
+ name: 'Frontend',
+ parentId: 'team-2',
+ children: {}
+ }
+ }
+ }
+ }
+ },
+ 'team-2': {
+ id: 'team-2',
+ name: 'Engineering',
+ parentId: 'team-1',
+ children: {
+ 'team-3': {
+ id: 'team-3',
+ name: 'Frontend',
+ parentId: 'team-2',
+ children: {}
+ }
+ }
+ },
+ 'team-3': {
+ id: 'team-3',
+ name: 'Frontend',
+ parentId: 'team-2',
+ children: {}
+ }
+ }
+ })
+
+ // Act & Assert
+ expect(() => hierarchy.removeTeam('team-2')).toThrow()
+ })
+ })
+})
\ No newline at end of file
diff --git a/lib/domain/__tests__/TeamHierarchyRepository.test.ts b/lib/domain/__tests__/TeamHierarchyRepository.test.ts
new file mode 100644
index 0000000..b6c578d
--- /dev/null
+++ b/lib/domain/__tests__/TeamHierarchyRepository.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest'
+import { TeamHierarchy } from '@/lib/domain/TeamHierarchy'
+import { TeamHierarchyRepository } from '@/lib/domain/TeamHierarchyRepository'
+
+// Mock implementation of the repository for testing
+class MockTeamHierarchyRepository implements TeamHierarchyRepository {
+ private hierarchies: Record = {}
+
+ async getByOrganisationId(organisationId: string): Promise {
+ return this.hierarchies[organisationId] || null
+ }
+
+ async save(organisationId: string, hierarchy: TeamHierarchy): Promise {
+ this.hierarchies[organisationId] = hierarchy
+ }
+}
+
+describe('TeamHierarchyRepository', () => {
+ describe('getByOrganisationId', () => {
+ it('should return null when no hierarchy exists for the organisation', async () => {
+ // Arrange
+ const repository = new MockTeamHierarchyRepository()
+ const organisationId = 'org-1'
+
+ // Act
+ const result = await repository.getByOrganisationId(organisationId)
+
+ // Assert
+ expect(result).toBeNull()
+ })
+
+ it('should return the hierarchy when it exists for the organisation', async () => {
+ // Arrange
+ const repository = new MockTeamHierarchyRepository()
+ const organisationId = 'org-1'
+ const hierarchy = TeamHierarchy.create({ teams: {} })
+ await repository.save(organisationId, hierarchy)
+
+ // Act
+ const result = await repository.getByOrganisationId(organisationId)
+
+ // Assert
+ expect(result).not.toBeNull()
+ expect(result).toEqual(hierarchy)
+ })
+ })
+
+ describe('save', () => {
+ it('should save a new hierarchy for an organisation', async () => {
+ // Arrange
+ const repository = new MockTeamHierarchyRepository()
+ const organisationId = 'org-1'
+ const hierarchy = TeamHierarchy.create({ teams: {} })
+
+ // Act
+ await repository.save(organisationId, hierarchy)
+ const savedHierarchy = await repository.getByOrganisationId(organisationId)
+
+ // Assert
+ expect(savedHierarchy).toEqual(hierarchy)
+ })
+
+ it('should update an existing hierarchy for an organisation', async () => {
+ // Arrange
+ const repository = new MockTeamHierarchyRepository()
+ const organisationId = 'org-1'
+
+ // Initial hierarchy
+ const initialHierarchy = TeamHierarchy.create({ teams: {} })
+ await repository.save(organisationId, initialHierarchy)
+
+ // Updated hierarchy with a team
+ const updatedHierarchy = TeamHierarchy.create({
+ teams: {
+ 'team-1': {
+ id: 'team-1',
+ name: 'Leadership Team',
+ parentId: null,
+ children: {}
+ }
+ }
+ })
+
+ // Act
+ await repository.save(organisationId, updatedHierarchy)
+ const savedHierarchy = await repository.getByOrganisationId(organisationId)
+
+ // Assert
+ expect(savedHierarchy).toEqual(updatedHierarchy)
+ expect(savedHierarchy).not.toEqual(initialHierarchy)
+ })
+ })
+})
\ No newline at end of file
diff --git a/lib/domain/__tests__/WorkflowNavigator.test.ts b/lib/domain/__tests__/WorkflowNavigator.test.ts
new file mode 100644
index 0000000..2a165ca
--- /dev/null
+++ b/lib/domain/__tests__/WorkflowNavigator.test.ts
@@ -0,0 +1,69 @@
+import {
+ DecisionWorkflowSteps,
+ WorkflowNavigator,
+} from '@/lib/domain/Decision';
+import { describe, it, expect} from 'vitest'
+
+describe('WorkflowNavigator', () => {
+ describe('getStepIndex', () => {
+ it('should return correct indices for valid steps', () => {
+ expect(WorkflowNavigator.getStepIndex(DecisionWorkflowSteps.IDENTIFY)).toBe(0);
+ expect(WorkflowNavigator.getStepIndex(DecisionWorkflowSteps.STAKEHOLDERS)).toBe(1);
+ expect(WorkflowNavigator.getStepIndex(DecisionWorkflowSteps.METHOD)).toBe(2);
+ expect(WorkflowNavigator.getStepIndex(DecisionWorkflowSteps.CHOOSE)).toBe(3);
+ expect(WorkflowNavigator.getStepIndex(DecisionWorkflowSteps.PUBLISH)).toBe(4);
+ });
+
+ it('should throw error for invalid step', () => {
+ const invalidStep = { key: 'invalid', label: 'Invalid', icon: DecisionWorkflowSteps.IDENTIFY.icon };
+ expect(() => WorkflowNavigator.getStepIndex(invalidStep)).toThrow('Invalid workflow step: invalid');
+ });
+ });
+
+ describe('step navigation', () => {
+ it('should correctly identify first/last steps', () => {
+ expect(WorkflowNavigator.isFirstStep(DecisionWorkflowSteps.IDENTIFY)).toBe(true);
+ expect(WorkflowNavigator.isFirstStep(DecisionWorkflowSteps.STAKEHOLDERS)).toBe(false);
+ expect(WorkflowNavigator.isLastStep(DecisionWorkflowSteps.PUBLISH)).toBe(true);
+ expect(WorkflowNavigator.isLastStep(DecisionWorkflowSteps.CHOOSE)).toBe(false);
+ });
+
+ it('should return correct previous steps', () => {
+ expect(WorkflowNavigator.getPreviousStep(DecisionWorkflowSteps.IDENTIFY)).toBeNull();
+ expect(WorkflowNavigator.getPreviousStep(DecisionWorkflowSteps.STAKEHOLDERS)).toEqual(DecisionWorkflowSteps.IDENTIFY);
+ expect(WorkflowNavigator.getPreviousStep(DecisionWorkflowSteps.METHOD)).toEqual(DecisionWorkflowSteps.STAKEHOLDERS);
+ expect(WorkflowNavigator.getPreviousStep(DecisionWorkflowSteps.CHOOSE)).toEqual(DecisionWorkflowSteps.METHOD);
+ expect(WorkflowNavigator.getPreviousStep(DecisionWorkflowSteps.PUBLISH)).toEqual(DecisionWorkflowSteps.CHOOSE);
+ });
+
+ it('should return correct next steps', () => {
+ expect(WorkflowNavigator.getNextStep(DecisionWorkflowSteps.IDENTIFY)).toEqual(DecisionWorkflowSteps.STAKEHOLDERS);
+ expect(WorkflowNavigator.getNextStep(DecisionWorkflowSteps.STAKEHOLDERS)).toEqual(DecisionWorkflowSteps.METHOD);
+ expect(WorkflowNavigator.getNextStep(DecisionWorkflowSteps.METHOD)).toEqual(DecisionWorkflowSteps.CHOOSE);
+ expect(WorkflowNavigator.getNextStep(DecisionWorkflowSteps.CHOOSE)).toEqual(DecisionWorkflowSteps.PUBLISH);
+ expect(WorkflowNavigator.getNextStep(DecisionWorkflowSteps.PUBLISH)).toBeNull();
+ });
+ });
+
+ describe('transition validation', () => {
+ it('should allow moving one step forward', () => {
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.IDENTIFY, DecisionWorkflowSteps.STAKEHOLDERS)).toBe(true);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.STAKEHOLDERS, DecisionWorkflowSteps.METHOD)).toBe(true);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.METHOD, DecisionWorkflowSteps.CHOOSE)).toBe(true);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.CHOOSE, DecisionWorkflowSteps.PUBLISH)).toBe(true);
+ });
+
+ it('should allow moving one step backward', () => {
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.STAKEHOLDERS, DecisionWorkflowSteps.IDENTIFY)).toBe(true);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.METHOD, DecisionWorkflowSteps.STAKEHOLDERS)).toBe(true);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.CHOOSE, DecisionWorkflowSteps.METHOD)).toBe(true);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.PUBLISH, DecisionWorkflowSteps.CHOOSE)).toBe(true);
+ });
+
+ it('should not allow skipping steps', () => {
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.IDENTIFY, DecisionWorkflowSteps.METHOD)).toBe(false);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.METHOD, DecisionWorkflowSteps.PUBLISH)).toBe(false);
+ expect(WorkflowNavigator.isValidTransition(DecisionWorkflowSteps.PUBLISH, DecisionWorkflowSteps.IDENTIFY)).toBe(false);
+ });
+ });
+});
\ No newline at end of file
diff --git a/lib/domain/decisionRelationshipRepository.ts b/lib/domain/decisionRelationshipRepository.ts
deleted file mode 100644
index a42a7e5..0000000
--- a/lib/domain/decisionRelationshipRepository.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { DecisionRelationship } from '@/lib/domain/DecisionRelationship'
-import { Decision } from '@/lib/domain/Decision'
-
-export interface DecisionRelationshipRepository {
- subscribeToDecisionRelationships(
- decision: Decision,
- onData: (relationships: DecisionRelationship[]) => void,
- onError: (error: Error) => void
- ): () => void;
-
- subscribeToAllRelationships(
- scope: { organisationId: string; teamId: string; projectId: string },
- onData: (relationships: DecisionRelationship[]) => void,
- onError: (error: Error) => void
- ): () => void;
-
- addRelationship(
- relationship: DecisionRelationship
- ): Promise;
-
- removeRelationship(
- relationship: DecisionRelationship
- ): Promise;
-}
\ No newline at end of file
diff --git a/lib/domain/decisionsRepository.ts b/lib/domain/decisionsRepository.ts
index fce7086..c260ff5 100644
--- a/lib/domain/decisionsRepository.ts
+++ b/lib/domain/decisionsRepository.ts
@@ -1,9 +1,7 @@
-import { Decision, DecisionProps } from "./Decision";
+import { Decision, DecisionProps, DecisionRelationship } from "./Decision";
export interface DecisionScope {
organisationId: string;
- teamId: string;
- projectId: string;
}
export interface DecisionsRepository {
@@ -13,7 +11,7 @@ export interface DecisionsRepository {
initialData: Partial>,
scope: DecisionScope,
): Promise;
- update(decision: Decision, scope: DecisionScope): Promise;
+ update(decision: Decision): Promise;
delete(id: string, scope: DecisionScope): Promise;
subscribeToAll(
onData: (decisions: Decision[]) => void,
@@ -21,9 +19,23 @@ export interface DecisionsRepository {
scope: DecisionScope,
): () => void;
subscribeToOne(
- id: string,
+ decision: Decision,
onData: (decision: Decision | null) => void,
onError: (error: Error) => void,
- scope: DecisionScope,
): () => void;
+
+ // Updated relationship methods
+ addRelationship(
+ sourceDecision: Decision,
+ targetDecisionRelationship: DecisionRelationship,
+ ): Promise;
+
+ removeRelationship(
+ sourceDecision: Decision,
+ targetDecisionRelationship: DecisionRelationship,
+ ): Promise;
+
+ // New methods for filtering by team and project
+ getByTeam(teamId: string, scope: DecisionScope): Promise;
+ getByProject(projectId: string, scope: DecisionScope): Promise;
}
diff --git a/lib/domain/organisationsRepository.ts b/lib/domain/organisationsRepository.ts
index 3c3779e..5d9ff43 100644
--- a/lib/domain/organisationsRepository.ts
+++ b/lib/domain/organisationsRepository.ts
@@ -6,4 +6,5 @@ export interface OrganisationsRepository {
getForStakeholder(stakeholderEmail: string): Promise
update(organisation: Organisation): Promise
delete(id: string): Promise
+ getAll(): Promise
}
\ No newline at end of file
diff --git a/lib/domain/projectDecisionsRepository.ts b/lib/domain/projectDecisionsRepository.ts
index 0519ecb..c94f3c6 100644
--- a/lib/domain/projectDecisionsRepository.ts
+++ b/lib/domain/projectDecisionsRepository.ts
@@ -1 +1,32 @@
-
\ No newline at end of file
+import { Project } from '@/lib/domain/Project'
+
+export interface ProjectDecisionsRepository {
+ /**
+ * Creates a new project
+ * @param project The project to create
+ * @returns The created project
+ */
+ create(project: Project): Promise
+
+ /**
+ * Deletes a project
+ * @param project The project to delete
+ * @returns Promise that resolves when the project is deleted
+ */
+ delete(project: Project): Promise
+
+ /**
+ * Updates an existing project
+ * @param project The project with updated values
+ * @returns The updated project
+ */
+ update(project: Project): Promise
+
+ /**
+ * Retrieves a project by its ID within the scope of an organisation
+ * @param organisationId The ID of the organisation the project belongs to
+ * @param projectId The ID of the project to retrieve
+ * @returns The project if found, null otherwise
+ */
+ getById(organisationId: string, projectId: string): Promise
+}
\ No newline at end of file
diff --git a/lib/firebase-admin.ts b/lib/firebase-admin.ts
index b258c2a..0a1122c 100644
--- a/lib/firebase-admin.ts
+++ b/lib/firebase-admin.ts
@@ -1,7 +1,8 @@
import { config } from 'dotenv'
import { resolve } from 'path'
-import { initializeApp, getApps, cert } from 'firebase-admin/app'
+import { initializeApp, getApps, cert, getApp } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
+import { getAuth } from 'firebase-admin/auth'
// Simulate NextJS behaviour)
// .env will be loaded automatically by dotenv
@@ -9,16 +10,39 @@ config({ path: resolve(process.cwd(), '.env.development') })
config({ path: resolve(process.cwd(), '.env.production') })
if (!getApps().length) {
- const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON
- // console.log('Raw JSON from env:', serviceAccountJson?.substring(0, 500) + '...')
- if (!serviceAccountJson) {
- throw new Error('FIREBASE_SERVICE_ACCOUNT_JSON environment variable is not set')
- }
+ if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
+ // Use emulator in development/test
+ process.env.FIREBASE_AUTH_EMULATOR_HOST = '127.0.0.1:9099';
+ process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080';
+ initializeApp({ projectId: 'decision-copilot' });
+ console.log('Admin SDK initialized with emulators');
+ } else {
+ const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON
+ if (!serviceAccountJson) {
+ throw new Error('FIREBASE_SERVICE_ACCOUNT_JSON environment variable is not set')
+ }
- const serviceAccount = JSON.parse(serviceAccountJson)
- initializeApp({
- credential: cert(serviceAccount)
- });
+ const serviceAccount = JSON.parse(serviceAccountJson)
+ initializeApp({
+ credential: cert(serviceAccount)
+ });
+ console.log('Admin SDK initialized with service account');
+ }
}
-export const adminDb = getFirestore()
\ No newline at end of file
+// Use the named database in production, default database otherwise
+const isProduction = process.env.NODE_ENV === 'production';
+const app = getApp();
+export const adminDb = isProduction
+ ? getFirestore(app, 'decision-copilot-prod')
+ : getFirestore();
+
+// Export Auth for user management
+export const adminAuth = getAuth(app);
+
+/**
+ * Get the list of admin users from environment variables
+ */
+export function getAdminUsers(): string[] {
+ return (process.env.ADMIN_USERS || '').split(',').map(email => email.trim());
+}
\ No newline at end of file
diff --git a/lib/firebase.ts b/lib/firebase.ts
index 157ccae..84ea78a 100644
--- a/lib/firebase.ts
+++ b/lib/firebase.ts
@@ -23,7 +23,12 @@ function createFirebaseApp() {
}
const app = createFirebaseApp();
-export const db = getFirestore(app);
+
+const firestoreDatabaseId = process.env.NEXT_PUBLIC_FIREBASE_FIRESTORE_DATABASE_ID || '(default)';
+export const db = (firestoreDatabaseId === '(default)')
+ ? getFirestore(app)
+ : getFirestore(app, firestoreDatabaseId);
+
export const auth = getAuth(app);
export const functions = getFunctions(app);
diff --git a/lib/infrastructure/__tests__/firestoreDecisionRelationshipRepository.integration.test.ts b/lib/infrastructure/__tests__/firestoreDecisionRelationshipRepository.integration.test.ts
deleted file mode 100644
index c41595d..0000000
--- a/lib/infrastructure/__tests__/firestoreDecisionRelationshipRepository.integration.test.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import { describe, it, expect, afterEach, afterAll, beforeAll, vi } from 'vitest'
-import { FirestoreDecisionsRepository } from '@/lib/infrastructure/firestoreDecisionsRepository'
-import { FirestoreDecisionRelationshipRepository } from '@/lib/infrastructure/firestoreDecisionRelationshipRepository'
-import { Decision } from '@/lib/domain/Decision'
-import { DecisionRelationship } from '@/lib/domain/DecisionRelationship'
-import { TEST_SCOPE, signInTestUser } from './helpers/firebaseTestHelper'
-import { DecisionRelationshipError } from '@/lib/domain/DecisionError'
-
-describe('FirestoreDecisionRelationshipRepository Integration Tests', () => {
- const repository = new FirestoreDecisionRelationshipRepository();
- const decisionsRepository = new FirestoreDecisionsRepository();
- let decisionA: Decision;
- let decisionB: Decision;
- let decisionC: Decision;
- const relationshipsToCleanUp: DecisionRelationship[] = [];
-
- beforeAll(async () => {
- await signInTestUser()
- const emptyDecision = Decision.createEmptyDecision();
-
- // Create test decisions
- decisionA = await decisionsRepository.create(
- emptyDecision
- .with({ title: 'Decision A' })
- .withoutId(),
- TEST_SCOPE
- );
-
- decisionB = await decisionsRepository.create(
- emptyDecision
- .with({ title: 'Decision B' })
- .withoutId(),
- TEST_SCOPE
- );
-
- decisionC = await decisionsRepository.create(
- emptyDecision
- .with({ title: 'Decision C' })
- .withoutId(),
- TEST_SCOPE
- );
- })
-
- afterEach(async () => {
- console.debug(`cleaning up relationships created by this test: ${relationshipsToCleanUp.map(r => r.id).join(', ')}`);
- for (const relationship of relationshipsToCleanUp) {
- await repository.removeRelationship(relationship);
- }
- })
-
- afterAll(async () => {
- try {
- // Clean up test decisions
- await decisionsRepository.delete(decisionA.id, TEST_SCOPE);
- await decisionsRepository.delete(decisionB.id, TEST_SCOPE);
- await decisionsRepository.delete(decisionC.id, TEST_SCOPE);
- } catch (error) {
- console.error('Error in afterAll:', error);
- throw error;
- }
- })
-
- describe('subscribeToDecisionRelationships', () => {
- it('should receive updates when relationships are added', async () => {
- const relationshipsForAFromSubscribe: Map = new Map;
- let updatesRecievedForA = 0;
- const relationshipsForBFromSubscribe: Map = new Map;
- let updatesRecievedForB = 0;
- const relationshipsForCFromSubscribe: Map = new Map;
- let updatesRecievedForC = 0;
- const onError = vi.fn()
-
- const allUpdatesReceived = new Promise((resolve) => {
- const checkIfAllUpdatesReceived = () => {
- if (
- updatesRecievedForA === 1 &&
- updatesRecievedForB === 2 &&
- updatesRecievedForC === 1
- ) {
- console.debug('All expected updates received:');
- console.debug('relationships for A', relationshipsForAFromSubscribe);
- console.debug('relationships for B', relationshipsForBFromSubscribe);
- console.debug('relationships for C', relationshipsForCFromSubscribe);
-
- console.debug('Unsubscribing...');
- unsubscribeA();
- unsubscribeB();
- unsubscribeC();
- resolve();
- }
- };
-
- // Subscribe to changes
- const unsubscribeA = repository.subscribeToDecisionRelationships(
- decisionA,
- (relationships) => {
- updatesRecievedForA = updatesRecievedForA + 1;
- console.debug(`received update #${updatesRecievedForA} for subscription to Decision A`, relationships);
- relationshipsForAFromSubscribe.set(updatesRecievedForA, relationships);
- checkIfAllUpdatesReceived();
- },
- onError
- );
- const unsubscribeB = repository.subscribeToDecisionRelationships(
- decisionB,
- (relationships) => {
- updatesRecievedForB = updatesRecievedForB + 1;
- console.debug(`received update #${updatesRecievedForB} for subscription to Decision B`, relationships);
- relationshipsForBFromSubscribe.set(updatesRecievedForB, relationships);
- checkIfAllUpdatesReceived();
- },
- onError
- );
- const unsubscribeC = repository.subscribeToDecisionRelationships(
- decisionC,
- (relationships) => {
- updatesRecievedForC = updatesRecievedForC + 1;
- console.debug(`received update #${updatesRecievedForC} for subscription to Decision C`, relationships);
- relationshipsForCFromSubscribe.set(updatesRecievedForC, relationships);
- checkIfAllUpdatesReceived();
- },
- onError
- );
-
- // Create a blocking relationship
- const blockedRelationship = DecisionRelationship.createBlockedByRelationship(decisionA, decisionB);
- repository.addRelationship(blockedRelationship);
- relationshipsToCleanUp.push(blockedRelationship);
-
- // Create a superseding relationship
- const supersedingRelationship = DecisionRelationship.createSupersedesRelationship(decisionB, decisionC);
- repository.addRelationship(supersedingRelationship);
- relationshipsToCleanUp.push(supersedingRelationship);
- });
-
- // Wait for all updates to be received
- await allUpdatesReceived;
- // Verify results for decision A
- // we expect 1 relationship for A - the blocking relationship between A and B
- expect(relationshipsForAFromSubscribe.get(1)?.[0].type).toBe('blocked_by')
- expect(relationshipsForAFromSubscribe.get(1)?.[0].fromDecisionId).toBe(decisionA.id)
- expect(relationshipsForAFromSubscribe.get(1)?.[0].toDecisionId).toBe(decisionB.id)
-
- // Verify results for decision B
- // we expect 2 relationships for B (which will have come through in the 2nd update)
- // - the blocking relationship between A and B, and the superseding relationship between B and C
- const blockingReloadshipforB = relationshipsForBFromSubscribe.get(2)?.find(r => r.type === 'blocked_by')
- expect(blockingReloadshipforB?.fromDecisionId).toBe(decisionA.id)
- expect(blockingReloadshipforB?.toDecisionId).toBe(decisionB.id)
- const supersedingReloadshipforB = relationshipsForBFromSubscribe.get(2)?.find(r => r.type === 'supersedes')
- expect(supersedingReloadshipforB?.fromDecisionId).toBe(decisionB.id)
- expect(supersedingReloadshipforB?.toDecisionId).toBe(decisionC.id)
-
- // Verify results for decision C
- // we expect 1 relationship for C - the superseding relationship between B and C
- expect(relationshipsForCFromSubscribe.get(1)?.[0].type).toBe('supersedes')
- expect(relationshipsForCFromSubscribe.get(1)?.[0].fromDecisionId).toBe(decisionB.id)
- expect(relationshipsForCFromSubscribe.get(1)?.[0].toDecisionId).toBe(decisionC.id)
-
- // Verify that the onError callback was not called
- expect(onError).not.toHaveBeenCalled()
- })
- it('should receive updates when relationships are removed', async () => {
- const relationshipsForAFromSubscribe: Map = new Map;
- let updatesRecievedForA = 0;
- const relationshipsForBFromSubscribe: Map = new Map;
- let updatesRecievedForB = 0;
- const onError = vi.fn()
-
- // Create a blocking relationship
- const blockedRelationship = DecisionRelationship.createBlockedByRelationship(decisionA, decisionB);
- await repository.addRelationship(blockedRelationship);
- relationshipsToCleanUp.push(blockedRelationship);
-
- const allUpdatesReceived = new Promise((resolve) => {
- const checkIfAllUpdatesReceived = () => {
- if (
- updatesRecievedForA === 2 &&
- updatesRecievedForB === 2
- ) {
- console.debug('All expected updates received:');
- console.debug('relationships for A', relationshipsForAFromSubscribe);
- console.debug('relationships for B', relationshipsForBFromSubscribe);
- console.debug('Unsubscribing...');
- unsubscribeA();
- unsubscribeB();
- resolve();
- }
- };
-
- // Subscribe to changes
- const unsubscribeA = repository.subscribeToDecisionRelationships(
- decisionA,
- (relationships) => {
- updatesRecievedForA = updatesRecievedForA + 1;
- console.debug(`received update #${updatesRecievedForA} for subscription to Decision A`, relationships);
- relationshipsForAFromSubscribe.set(updatesRecievedForA, relationships);
- checkIfAllUpdatesReceived();
- },
- onError
- );
- const unsubscribeB = repository.subscribeToDecisionRelationships(
- decisionB,
- (relationships) => {
- updatesRecievedForB = updatesRecievedForB + 1;
- console.debug(`received update #${updatesRecievedForB} for subscription to Decision B`, relationships);
- relationshipsForBFromSubscribe.set(updatesRecievedForB, relationships);
- checkIfAllUpdatesReceived();
- },
- onError
- );
-
- // Remove the blocking relationship
- repository.removeRelationship(blockedRelationship);
- });
-
- // Wait for all updates to be received
- await allUpdatesReceived;
-
- // The first update for each should have been registering the initial relationship
- expect(relationshipsForAFromSubscribe.get(1)?.length).toBe(1)
- expect(relationshipsForBFromSubscribe.get(1)?.length).toBe(1)
-
- // The second update for each should been registering removal of the relationship
- expect(relationshipsForAFromSubscribe.get(2)?.length).toBe(0)
- expect(relationshipsForBFromSubscribe.get(2)?.length).toBe(0)
-
- // Verify that the onError callback was not called
- expect(onError).not.toHaveBeenCalled()
- })
- })
-
- describe('blocking relationships', () => {
- it('should allow a decision to be blocked by 2 decisions', async () => {
- // Mark A as blocked by B
- const blockedRelationshipAB = DecisionRelationship.createBlockedByRelationship(decisionA, decisionB);
- await repository.addRelationship(blockedRelationshipAB);
- relationshipsToCleanUp.push(blockedRelationshipAB);
-
- // Mark A as blocked by C
- const blockedRelationshipAC = DecisionRelationship.createBlockedByRelationship(decisionA, decisionC);
- await repository.addRelationship(blockedRelationshipAC);
- relationshipsToCleanUp.push(blockedRelationshipAC);
- })
-
- it('should prevent cyclic blocking relationships', async () => {
- // Create A blocks B
- const blockedRelationshipAB = DecisionRelationship.createBlockedByRelationship(decisionA, decisionB);
- await repository.addRelationship(blockedRelationshipAB);
- relationshipsToCleanUp.push(blockedRelationshipAB);
-
- // Create B blocks C
- const blockedRelationshipBC = DecisionRelationship.createBlockedByRelationship(decisionB, decisionC);
- await repository.addRelationship(blockedRelationshipBC);
- relationshipsToCleanUp.push(blockedRelationshipBC);
-
- // Attempt to create C blocks A (which would create a cycle)
- await expect(repository.addRelationship(DecisionRelationship.createBlockedByRelationship(decisionC, decisionA)))
- .rejects
- .toThrow(DecisionRelationshipError);
- })
- })
-
- describe('superseding relationships', () => {
- it('should prevent superceding an already superceded decision', async () => {
- const supersedingRelationshipBA = DecisionRelationship.createSupersedesRelationship(decisionB, decisionA);
- const supersedingRelationshipBC = DecisionRelationship.createSupersedesRelationship(decisionB, decisionC);
- relationshipsToCleanUp.push(supersedingRelationshipBA);
- relationshipsToCleanUp.push(supersedingRelationshipBC);
-
- // Adding the first superceded relationship for Decision B is fine
- await repository.addRelationship(supersedingRelationshipBA);
-
- // Attempting to add another superseded relationship for Decision B should fail
- await expect(repository.addRelationship(supersedingRelationshipBC))
- .rejects
- .toThrow(DecisionRelationshipError);
- })
-
- it('should prevent cyclic superseding relationships', async () => {
- // Create A supersedes B
- const supersedingRelationshipAB = DecisionRelationship.createSupersedesRelationship(decisionA, decisionB);
- await repository.addRelationship(supersedingRelationshipAB);
- relationshipsToCleanUp.push(supersedingRelationshipAB);
-
- // Create B supersedes C
- const supersedingRelationshipBC = DecisionRelationship.createSupersedesRelationship(decisionB, decisionC);
- await repository.addRelationship(supersedingRelationshipBC);
- relationshipsToCleanUp.push(supersedingRelationshipBC);
-
- // Attempt to create C supersedes A (which would create a cycle)
- await expect(repository.addRelationship(DecisionRelationship.createSupersedesRelationship(decisionC, decisionA)))
- .rejects
- .toThrow(DecisionRelationshipError);
- })
- })
-})
\ No newline at end of file
diff --git a/lib/infrastructure/__tests__/firestoreDecisionsRepository.integration.test.ts b/lib/infrastructure/__tests__/firestoreDecisionsRepository.integration.test.ts
index 88ab401..3986a43 100644
--- a/lib/infrastructure/__tests__/firestoreDecisionsRepository.integration.test.ts
+++ b/lib/infrastructure/__tests__/firestoreDecisionsRepository.integration.test.ts
@@ -1,70 +1,103 @@
-import { describe, it, expect, beforeEach, afterEach, beforeAll, vi } from 'vitest'
+import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest'
import { FirestoreDecisionsRepository } from '@/lib/infrastructure/firestoreDecisionsRepository'
-import { Decision } from '@/lib/domain/Decision'
-import { TEST_SCOPE, signInTestUser } from './helpers/firebaseTestHelper'
-import { FirestoreDecisionRelationshipRepository } from '@/lib/infrastructure/firestoreDecisionRelationshipRepository';
-import { DecisionRelationship } from '@/lib/domain/DecisionRelationship';
+import { Decision, DecisionRelationship } from '@/lib/domain/Decision'
+import { BASE_TEST_SCOPE, signInTestUser } from './helpers/firebaseTestHelper'
+import { Project } from '@/lib/domain/Project'
+import { FirestoreProjectDecisionsRepository } from '../firestoreProjectDecisionsRepository'
describe('FirestoreDecisionsRepository Integration Tests', () => {
const repository = new FirestoreDecisionsRepository();
- const decisionRelationshipRepository = new FirestoreDecisionRelationshipRepository();
- const emptyDecision = Decision.createEmptyDecision();
- let sampleDecision: Decision;
- let sampleDecision2: Decision;
- const relationshipsToCleanUp: DecisionRelationship[] = [];
+ const projectRepository = new FirestoreProjectDecisionsRepository();
+ const decisionsToCleanUp: Decision[] = [];
+ const projectsToCleanUp: Project[] = [];
+
+ const createTestProjectAndDecisions = async (testName: string): Promise => {
+ const randomChars = Math.random().toString(36).substring(5, 9);
+ const emptyDecision = Decision.createEmptyDecision();
+ const projectId = `test-${testName}-${randomChars}`;
+ const teamId = `team-${testName}-${randomChars}`;
+
+ const project = await projectRepository.create(Project.create({
+ id: projectId,
+ name: `Test Project ${testName}`,
+ description: `Temporary project for integration tests ${testName}`,
+ organisationId: BASE_TEST_SCOPE.organisationId,
+ }));
+
+ projectsToCleanUp.push(project);
+
+ const decisionScope = {
+ organisationId: project.organisationId,
+ }
- beforeAll(async () => {
- await signInTestUser()
- })
+ const decisionA = await repository.create(
+ emptyDecision
+ .with({
+ title: 'Decision A',
+ teamIds: [teamId],
+ projectIds: [project.id]
+ })
+ .withoutId(),
+ decisionScope
+ );
+
+ decisionsToCleanUp.push(decisionA);
- beforeEach(async () => {
- sampleDecision = await repository.create(
+ const decisionA1 = await repository.create(
emptyDecision
- .with({ title: 'Sample Decision' })
- .withoutId(),
- TEST_SCOPE
+ .with({
+ title: 'Decision A1',
+ teamIds: [teamId],
+ projectIds: [project.id]
+ })
+ .withoutId(),
+ decisionScope
);
- sampleDecision2 = await repository.create(
+
+ decisionsToCleanUp.push(decisionA1);
+
+ const decisionB = await repository.create(
emptyDecision
- .with({ title: 'Sample Decision 2 (superceeds Sample Decision)' })
+ .with({
+ title: 'Decision B',
+ teamIds: [teamId],
+ projectIds: [project.id]
+ })
.withoutId(),
- TEST_SCOPE
+ decisionScope
);
- })
-
- afterEach(async () => {
- console.debug(`cleaning up relationships created by this test: ${relationshipsToCleanUp.map(r => r.id).join(', ')}`);
- for (const relationship of relationshipsToCleanUp) {
- await decisionRelationshipRepository.removeRelationship(relationship);
- }
- await repository.delete(sampleDecision.id, TEST_SCOPE);
- await repository.delete(sampleDecision2.id, TEST_SCOPE);
- })
- describe('create', () => {
- it('should have id, projectId, teamId, organisationId set to the scope values', async () => {
+ decisionsToCleanUp.push(decisionB);
+
+ return [decisionA, decisionA1, decisionB];
+ }
+
+ beforeAll(async () => {
+ await signInTestUser()
+ })
- expect(sampleDecision.id).toBeDefined();
- expect(sampleDecision.projectId).toBe(TEST_SCOPE.projectId);
- expect(sampleDecision.teamId).toBe(TEST_SCOPE.teamId);
- expect(sampleDecision.organisationId).toBe(TEST_SCOPE.organisationId);
- })
+ afterAll(async () => {
+ for (const decision of decisionsToCleanUp) {
+ await repository.delete(decision.id, {
+ organisationId: decision.organisationId,
+ });
+ }
+ for (const project of projectsToCleanUp) {
+ await projectRepository.delete(project);
+ }
})
describe('subscribeToOne', () => {
- it('should receive updates when a decision is modified', async () => {
- const decisionId = sampleDecision.id;
- const decisionsFromSubscribeToOne: Map = new Map();
- let updatesRecievedFromSubscribeToOne = 0;
- const onError = vi.fn()
-
+ it('should receive updates when a decision title is modified', async () => {
+ const onError = vi.fn();
+ let updatesReceived = 0;
+ const decisionsReceived: Map = new Map();
+ const [decisionA] = await createTestProjectAndDecisions('subscribeToOne');
+
// Create a promise that resolves when we get both the initial and updated decision
- const allUpdatesReceived = new Promise(async (resolve) => {
+ const allUpdatesReceived = new Promise((resolve) => {
const checkIfAllUpdatesReceived = () => {
- if (updatesRecievedFromSubscribeToOne === 2) {
- console.debug('All expected updates received:');
- console.debug('decisions from subscribeToOne', decisionsFromSubscribeToOne);
- console.debug('unsubscribing from subscribeToOne');
+ if (updatesReceived === 2) {
unsubscribe();
resolve();
}
@@ -72,114 +105,142 @@ describe('FirestoreDecisionsRepository Integration Tests', () => {
// Subscribe to changes
const unsubscribe = repository.subscribeToOne(
- decisionId,
+ decisionA,
(decision) => {
- console.debug('Received decision update:', decision?.title ?? 'null')
- updatesRecievedFromSubscribeToOne = updatesRecievedFromSubscribeToOne + 1;
- decisionsFromSubscribeToOne.set(updatesRecievedFromSubscribeToOne, decision)
+ updatesReceived++;
+ decisionsReceived.set(updatesReceived, decision);
+
+ // Wait until we've received the initial update before making any changes
+ if (updatesReceived === 1) {
+ // Make a change to the decision title to trigger an update
+ repository.update(
+ decisionA.with({ title: decisionA.title + ' Updated' })
+ );
+ }
+
checkIfAllUpdatesReceived();
},
onError,
- TEST_SCOPE
- );
-
- // Update the decision title
- repository.update(
- sampleDecision.with({ title: 'Sample Decision [UPDATED]' }),
- TEST_SCOPE
);
-
- return unsubscribe;
});
-
+
// Wait for both updates to be received
await allUpdatesReceived;
-
- // Verify results
- expect(onError).not.toHaveBeenCalled()
- // We expect 2 updates, the initial and the updated decision
- expect(decisionsFromSubscribeToOne.size).toBe(2)
+ expect(onError).not.toHaveBeenCalled();
+
+ // Initial decision had the original title
+ const initialDecision = decisionsReceived.get(1);
+ expect(initialDecision?.title).toBe('Decision A');
- // The first update should be the initial decision
- expect(decisionsFromSubscribeToOne.get(1)?.title).toBe('Sample Decision')
+ // Updated decision has the new title
+ const updatedDecision = decisionsReceived.get(2);
+ expect(updatedDecision?.title).toBe('Decision A Updated');
+ });
- // The second update should be the updated decision
- expect(decisionsFromSubscribeToOne.get(2)?.title).toBe('Sample Decision [UPDATED]')
- })
+ it('should receive updates when a relationship is added or removed', async () => {
+ const onError = vi.fn();
+ let updatesReceived = 0;
+ let hasAddedRelationship = false;
+ let hasRemovedRelationship = false;
+ const decisionsReceived: Map = new Map();
+ const [decisionA, decisionB] = await createTestProjectAndDecisions('subscribeToOne-relationship');
- it('changes to decision relationships update the affected decisions', async () => {
- const decisionsFromSubscribeToOne: Map = new Map();
- let updatesRecievedFromSubscribeToOne = 0;
- const onError = vi.fn()
-
// Create a promise that resolves when we get both the initial and updated decision
- const allUpdatesReceived = new Promise( (resolve) => {
+ const allUpdatesReceived = new Promise((resolve) => {
const checkIfAllUpdatesReceived = () => {
- if (updatesRecievedFromSubscribeToOne === 2) {
- console.debug('All expected updates received:');
- console.debug('decisions from subscribeToOne', decisionsFromSubscribeToOne);
- console.debug('unsubscribing from subscribeToOne');
+ if (hasAddedRelationship && hasRemovedRelationship) {
unsubscribe();
resolve();
}
};
-
+
// Subscribe to changes
const unsubscribe = repository.subscribeToOne(
- sampleDecision2.id,
- (decision) => {
- console.debug('decision from subscribeToOne', decision);
- updatesRecievedFromSubscribeToOne = updatesRecievedFromSubscribeToOne + 1;
- decisionsFromSubscribeToOne.set(updatesRecievedFromSubscribeToOne, decision)
+ decisionA,
+ async (decision) => {
+ updatesReceived++;
+ console.log(`[subscribeToOne] Received update ${updatesReceived}`);
+ decisionsReceived.set(updatesReceived, decision);
+
+ // Wait until we've received the initial update before making any changes
+ if (updatesReceived === 1) {
+ console.log('[subscribeToOne] Adding relationship');
+ await repository.addRelationship(
+ decisionA,
+ {
+ targetDecision: decisionB.toDocumentReference(),
+ targetDecisionTitle: decisionB.title,
+ type: 'blocked_by'
+ } as DecisionRelationship
+ );
+ console.log('[subscribeToOne] Relationship added');
+ }
+
+ // Check if the relationship was added
+ const currentRelationships = decision?.getRelationshipsByType('blocked_by');
+ console.log(`[subscribeToOne] Update ${updatesReceived} - blocked_by relationships: ${currentRelationships?.length}`);
+
+ if (!hasAddedRelationship && currentRelationships?.length === 1) {
+ hasAddedRelationship = true;
+ console.log('[subscribeToOne] Removing relationship');
+ await repository.removeRelationship(
+ decisionA,
+ {
+ targetDecision: decisionB.toDocumentReference(),
+ targetDecisionTitle: decisionB.title,
+ type: 'blocked_by'
+ } as DecisionRelationship
+ );
+ console.log('[subscribeToOne] Relationship removed');
+ }
+
+ if (hasAddedRelationship && currentRelationships?.length === 0) {
+ hasRemovedRelationship = true;
+ }
+
checkIfAllUpdatesReceived();
},
onError,
- TEST_SCOPE
);
-
- // Add a relationship indicating the supersededDecision
- const supersededRelationship = DecisionRelationship.create({
- fromTeamId: sampleDecision2.teamId,
- fromProjectId: sampleDecision2.projectId,
- fromDecisionId: sampleDecision2.id,
- type: 'supersedes',
- toDecisionId: sampleDecision.id,
- toTeamId: sampleDecision.teamId,
- toProjectId: sampleDecision.projectId,
- organisationId: TEST_SCOPE.organisationId,
- createdAt: new Date(),
- });
- decisionRelationshipRepository.addRelationship(supersededRelationship);
- relationshipsToCleanUp.push(supersededRelationship);
});
// Wait for both updates to be received
await allUpdatesReceived;
- expect(onError).not.toHaveBeenCalled()
+ expect(onError).not.toHaveBeenCalled();
- // Initial decision has no relationships
- expect(decisionsFromSubscribeToOne.get(1)?.relationships?.length).toBe(0)
- // Updated decision has one supercedes relationship
- expect(decisionsFromSubscribeToOne.get(2)?.supersedes.length).toBe(1)
- expect(decisionsFromSubscribeToOne.get(2)?.supersedes[0].toDecisionId).toBe(sampleDecision.id)
- })
- })
+ // Find the update where the relationship was added
+ const addedRelationshipUpdate = Array.from(decisionsReceived.entries())
+ .find(([, decision]) => decision?.getRelationshipsByType('blocked_by').length === 1);
+ expect(addedRelationshipUpdate).toBeDefined();
+ const [, decisionWithRelationship] = addedRelationshipUpdate!;
+ expect(decisionWithRelationship?.getRelationshipsByType('blocked_by')[0].targetDecision.id).toBe(decisionB.id);
+ expect(decisionWithRelationship?.getRelationshipsByType('blocked_by')[0].targetDecisionTitle).toBe(decisionB.title);
+
+ // Find the update where the relationship was removed
+ const removedRelationshipUpdate = Array.from(decisionsReceived.entries())
+ .find(([, decision]) => decision?.getRelationshipsByType('blocked_by').length === 0);
+ expect(removedRelationshipUpdate).toBeDefined();
+ }, 20000);
+ });
describe('subscribeToAll', () => {
- it('should receive updates when a decision is modified', async () => {
- const decisionsFromSubscribeToAll: Map = new Map();
- let updatesReceivedFromSubscribeToAll = 0;
- const onError = vi.fn()
-
+ it('should receive updates when multiple decision titles are modified', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [decisionA, _, decisionB] = await createTestProjectAndDecisions('subscribeToAll');
+ const testProjectScope = {
+ organisationId: decisionA.organisationId,
+ }
+
+ const onError = vi.fn();
+ let updatesReceived = 0;
+ const decisionsReceived: Map = new Map();
+
// Create a promise that resolves when we get both the initial and updated decisions
- const allUpdatesReceived = new Promise(async (resolve) => {
+ const allUpdatesReceived = new Promise((resolve) => {
const checkIfAllUpdatesReceived = () => {
- if (updatesReceivedFromSubscribeToAll === 2) {
- console.debug('All expected updates received:');
- console.debug('decisions from subscribeToAll', decisionsFromSubscribeToAll);
- console.debug('unsubscribing from subscribeToAll');
+ if (updatesReceived === 3) {
unsubscribe();
resolve();
}
@@ -188,159 +249,267 @@ describe('FirestoreDecisionsRepository Integration Tests', () => {
// Subscribe to changes
const unsubscribe = repository.subscribeToAll(
(decisions) => {
- updatesReceivedFromSubscribeToAll = updatesReceivedFromSubscribeToAll + 1;
- console.debug(`Received decisions update #${updatesReceivedFromSubscribeToAll}:`, decisions.map(d => d.title));
- decisionsFromSubscribeToAll.set(updatesReceivedFromSubscribeToAll, decisions);
+ updatesReceived++;
+ decisionsReceived.set(updatesReceived, decisions);
+
+ // Wait until we've received the initial update before making any changes
+ if (updatesReceived === 1) {
+ // Make a change to the decision A
+ repository.update(
+ decisionA.with({ title: decisionA.title + ' Updated' })
+ );
+ }
+
+ // Wait until we've received the second update before making any changes
+ if (updatesReceived === 2) {
+ // Make a change to the decision B
+ repository.update(
+ decisionB.with({ title: decisionB.title + ' Updated' })
+ );
+ }
+
checkIfAllUpdatesReceived();
},
onError,
- TEST_SCOPE
+ testProjectScope
);
-
- // Update one of the decisions
- repository.update(
- sampleDecision.with({ title: 'Sample Decision [UPDATED FROM SUBSCRIBE ALL]' }),
- TEST_SCOPE
- );
-
- return unsubscribe;
});
-
- // Wait for both updates to be received
+
await allUpdatesReceived;
-
- // Verify results
- expect(onError).not.toHaveBeenCalled()
-
- // We expect 2 updates
- expect(decisionsFromSubscribeToAll.size).toBe(2)
-
- // Find the sample decision in each update
- const initialSampleDecision = decisionsFromSubscribeToAll.get(1)?.find(d => d.id === sampleDecision.id);
- const updatedSampleDecision = decisionsFromSubscribeToAll.get(2)?.find(d => d.id === sampleDecision.id);
-
- // The first update should have the initial title
- expect(initialSampleDecision?.title).toBe('Sample Decision')
-
- // The second update should have the updated title
- expect(updatedSampleDecision?.title).toBe('Sample Decision [UPDATED FROM SUBSCRIBE ALL]')
- })
-
- it('changes to decision relationships update the affected decisions in the list', async () => {
- const decisionsFromSubscribeToAll: Map = new Map();
- let updatesReceivedFromSubscribeToAll = 0;
- const onError = vi.fn()
- let unsubscribe: (() => void) | undefined;
-
- // Create a promise that resolves when we get both the initial and updated decisions
- const allUpdatesReceived = new Promise(async (resolve, reject) => {
- const timeoutId = setTimeout(() => {
- console.error('Test timed out. Current state:', {
- updatesReceived: updatesReceivedFromSubscribeToAll,
- decisions: decisionsFromSubscribeToAll
- });
- cleanup();
- reject(new Error('Test timed out waiting for updates'));
- }, 8000);
-
- const cleanup = () => {
- if (timeoutId) clearTimeout(timeoutId);
- if (unsubscribe) unsubscribe();
- };
+ expect(onError).not.toHaveBeenCalled();
+
+ // The first update should contain the initial decisions titles
+ const initialDecision = decisionsReceived.get(1)?.find(d => d.id === decisionA.id);
+ expect(initialDecision?.title).toBe('Decision A');
+
+ // The second update should contain the updated decision A title
+ const updatedDecision = decisionsReceived.get(2)?.find(d => d.id === decisionA.id);
+ expect(updatedDecision?.title).toBe('Decision A Updated');
+
+ // The third update should contain the updated decision B title
+ const updatedDecisionB = decisionsReceived.get(3)?.find(d => d.id === decisionB.id);
+ expect(updatedDecisionB?.title).toBe('Decision B Updated');
+ });
+
+ it('should receive to both decisions on both sides of a relationship when a relationship is added or removed', async () => {
+ const onError = vi.fn();
+ let updatesReceived = 0;
+ let hasAddedRelationship = false;
+ let hasRemovedRelationship = false;
+ const decisionsReceived: Map