diff --git a/backend/src/db_models.py b/backend/src/db_models.py index 78fcd5a..fa84ca2 100644 --- a/backend/src/db_models.py +++ b/backend/src/db_models.py @@ -286,6 +286,11 @@ class RequirementGroup(Base): ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + nullable=True + ) class Validation(Base): @@ -328,6 +333,7 @@ class RequirementHistory(Base): history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) original_req_id: Mapped[int] = mapped_column(Integer, nullable=False) project_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + status_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Requirement lifecycle status req_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True) req_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) priority_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) @@ -512,3 +518,29 @@ class RequirementCommentReply(Base): __table_args__ = ( Index("idx_rcr_parent", "parent_comment_id"), ) + + +class RequirementLinkHistory(Base): + """ + Historical records of requirement link changes (deletions). + Note: This is populated by a database trigger on UPDATE/DELETE. + Stores snapshots of relationship type names to preserve data if types are renamed/deleted. + """ + __tablename__ = "requirement_links_history" + + history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + original_link_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + source_req_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + target_req_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + relationship_type_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + relationship_type_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Preserved type name + inverse_type_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Preserved inverse name + created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # When link was created + valid_to: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # When link was deleted + + # Indexes + __table_args__ = ( + Index("idx_link_hist_source", "source_req_id"), + Index("idx_link_hist_target", "target_req_id"), + ) diff --git a/backend/src/main.py b/backend/src/main.py index 3b986a6..670fbd8 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -13,6 +13,7 @@ from src.models import ( ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest, RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest, RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult, + RequirementLinkHistoryResponse, RequirementGroupHistoryResponse, CurrentRequirementGroupResponse, RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES, CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest, RequirementStatusResponse, DeletedRequirementResponse @@ -1600,6 +1601,117 @@ async def delete_requirement_link( await db.commit() +@app.get("/api/requirements/{requirement_id}/links/history", response_model=List[RequirementLinkHistoryResponse]) +async def get_requirement_link_history( + requirement_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get the link history for a requirement (deleted/changed links). + Returns all historical links where this requirement was source or target. + + Args: + requirement_id: The requirement to get link history for + + Returns: + List of historical links with relationship type snapshots and requirement info. + """ + user = await _get_current_user_db(request, db) + + # Check if requirement exists + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(requirement_id) + if not requirement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Requirement with id {requirement_id} not found" + ) + + # Verify user is a member of the requirement's project + await _verify_project_membership(requirement.project_id, user.id, db) + + # Get link history + link_repo = RequirementLinkRepository(db) + history = await link_repo.get_history_by_requirement_id(requirement_id) + + return [RequirementLinkHistoryResponse(**h) for h in history] + + +@app.get("/api/requirements/{requirement_id}/groups/history", response_model=List[RequirementGroupHistoryResponse]) +async def get_requirement_group_history( + requirement_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get the group association history for a requirement (removed groups). + Returns all historical group associations for this requirement. + + Args: + requirement_id: The requirement to get group history for + + Returns: + List of historical group associations with group name/color snapshots. + """ + user = await _get_current_user_db(request, db) + + # Check if requirement exists + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(requirement_id) + if not requirement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Requirement with id {requirement_id} not found" + ) + + # Verify user is a member of the requirement's project + await _verify_project_membership(requirement.project_id, user.id, db) + + # Get group history + group_repo = GroupRepository(db) + history = await group_repo.get_group_history_by_requirement_id(requirement_id) + + return [RequirementGroupHistoryResponse(**h) for h in history] + + +@app.get("/api/requirements/{requirement_id}/groups/current", response_model=List[CurrentRequirementGroupResponse]) +async def get_requirement_current_groups( + requirement_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get the current groups for a requirement with their association timestamps. + Returns all current group associations with when they were added. + + Args: + requirement_id: The requirement to get current groups for + + Returns: + List of current group associations with group name/color and created_at timestamp. + """ + user = await _get_current_user_db(request, db) + + # Check if requirement exists + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(requirement_id) + if not requirement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Requirement with id {requirement_id} not found" + ) + + # Verify user is a member of the requirement's project + await _verify_project_membership(requirement.project_id, user.id, db) + + # Get current groups + group_repo = GroupRepository(db) + groups = await group_repo.get_current_groups_by_requirement_id(requirement_id) + + return [CurrentRequirementGroupResponse(**g) for g in groups] + + # =========================================== # Comment Endpoints # =========================================== diff --git a/backend/src/models.py b/backend/src/models.py index 390ecc7..e634539 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -351,6 +351,26 @@ class RequirementLinkCreateRequest(BaseModel): target_requirement_id: int +class RequirementLinkHistoryResponse(BaseModel): + """Response schema for requirement link history (deleted/changed links).""" + history_id: int + original_link_id: Optional[int] = None + source_req_id: Optional[int] = None + target_req_id: Optional[int] = None + source_tag_code: Optional[str] = None # Resolved from source requirement + target_tag_code: Optional[str] = None # Resolved from target requirement + source_req_name: Optional[str] = None + target_req_name: Optional[str] = None + relationship_type_snapshot: Optional[str] = None # Preserved type name + inverse_type_snapshot: Optional[str] = None # Preserved inverse name + created_by_username: Optional[str] = None + valid_from: Optional[datetime] = None # When link was created + valid_to: Optional[datetime] = None # When link was deleted + + class Config: + from_attributes = True + + # Requirement Search schemas class RequirementSearchResult(BaseModel): """Response schema for requirement search results (for autocomplete).""" @@ -402,3 +422,29 @@ class CommentCreateRequest(BaseModel): class ReplyCreateRequest(BaseModel): """Request schema for creating a reply.""" reply_text: str + + +class RequirementGroupHistoryResponse(BaseModel): + """Response schema for requirement group association history (removed groups).""" + history_id: int + requirement_id: int + group_id: int + group_name_snapshot: Optional[str] = None # Preserved group name at time of removal + group_hex_color_snapshot: Optional[str] = None # Preserved hex color at time of removal + valid_from: Optional[datetime] = None # When the group was associated + valid_to: Optional[datetime] = None # When the group was removed + + class Config: + from_attributes = True + + +class CurrentRequirementGroupResponse(BaseModel): + """Response schema for current group associations with timestamp.""" + requirement_id: int + group_id: int + group_name: str + group_hex_color: str + created_at: Optional[datetime] = None # When the group was added + + class Config: + from_attributes = True diff --git a/backend/src/repositories/group_repository.py b/backend/src/repositories/group_repository.py index 68f357f..23c8cda 100644 --- a/backend/src/repositories/group_repository.py +++ b/backend/src/repositories/group_repository.py @@ -1,8 +1,8 @@ """ Repository layer for Group database operations. """ -from typing import List, Optional -from sqlalchemy import select +from typing import List, Optional, Dict, Any +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from src.db_models import Group import logging @@ -74,3 +74,81 @@ class GroupRepository: await self.session.flush() await self.session.refresh(group) return group + + async def get_group_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]: + """ + Get the group association history for a requirement. + Returns records of when groups were added or removed from this requirement. + + Args: + requirement_id: The requirement ID to get group history for + + Returns: + List of historical group associations with snapshots of group data + """ + query = text(""" + SELECT + rgh.history_id, + rgh.requirement_id, + rgh.group_id, + rgh.group_name_snapshot, + rgh.group_hex_color_snapshot, + rgh.valid_from, + rgh.valid_to + FROM requirements_groups_history rgh + WHERE rgh.requirement_id = :requirement_id + ORDER BY rgh.valid_to DESC + """) + + result = await self.session.execute(query, {"requirement_id": requirement_id}) + rows = result.fetchall() + + return [ + { + "history_id": row.history_id, + "requirement_id": row.requirement_id, + "group_id": row.group_id, + "group_name_snapshot": row.group_name_snapshot, + "group_hex_color_snapshot": row.group_hex_color_snapshot, + "valid_from": row.valid_from, + "valid_to": row.valid_to, + } + for row in rows + ] + + async def get_current_groups_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]: + """ + Get the current groups associated with a requirement, including when they were added. + + Args: + requirement_id: The requirement ID to get groups for + + Returns: + List of current group associations with group data and created_at timestamp + """ + query = text(""" + SELECT + rg.requirement_id, + rg.group_id, + g.group_name, + g.hex_color, + rg.created_at + FROM requirements_groups rg + JOIN groups g ON rg.group_id = g.id + WHERE rg.requirement_id = :requirement_id + ORDER BY rg.created_at DESC + """) + + result = await self.session.execute(query, {"requirement_id": requirement_id}) + rows = result.fetchall() + + return [ + { + "requirement_id": row.requirement_id, + "group_id": row.group_id, + "group_name": row.group_name, + "group_hex_color": row.hex_color, + "created_at": row.created_at, + } + for row in rows + ] diff --git a/backend/src/repositories/requirement_link_repository.py b/backend/src/repositories/requirement_link_repository.py index 1a68023..b2af985 100644 --- a/backend/src/repositories/requirement_link_repository.py +++ b/backend/src/repositories/requirement_link_repository.py @@ -2,7 +2,7 @@ Repository for RequirementLink database operations. """ from typing import List, Optional, Dict, Any -from sqlalchemy import select, or_ +from sqlalchemy import select, or_, text from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession from src.db_models import RequirementLink, Requirement, RelationshipType, User @@ -197,3 +197,65 @@ class RequirementLinkRepository: ) ) return result.scalar_one_or_none() is not None + + async def get_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]: + """ + Get the link history for a requirement (both as source and target). + This data is populated by a database trigger on UPDATE/DELETE. + + Args: + requirement_id: The requirement ID to get link history for + + Returns: + List of historical links with resolved requirement names and creator info + """ + # Use raw SQL with LEFT JOINs to resolve foreign keys to display names + # Note: source/target requirements may have been deleted, so we use LEFT JOINs + query = text(""" + SELECT + lh.history_id, + lh.original_link_id, + lh.source_req_id, + lh.target_req_id, + lh.relationship_type_snapshot, + lh.inverse_type_snapshot, + lh.valid_from, + lh.valid_to, + u.full_name as created_by_full_name, + u.username as created_by_username, + sr.req_name as source_req_name, + st.tag_code as source_tag_code, + tr.req_name as target_req_name, + tt.tag_code as target_tag_code + FROM requirement_links_history lh + LEFT JOIN users u ON lh.created_by = u.id + LEFT JOIN requirements sr ON lh.source_req_id = sr.id + LEFT JOIN tags st ON sr.tag_id = st.id + LEFT JOIN requirements tr ON lh.target_req_id = tr.id + LEFT JOIN tags tt ON tr.tag_id = tt.id + WHERE lh.source_req_id = :requirement_id + OR lh.target_req_id = :requirement_id + ORDER BY lh.valid_to DESC + """) + + result = await self.db.execute(query, {"requirement_id": requirement_id}) + rows = result.fetchall() + + return [ + { + "history_id": row.history_id, + "original_link_id": row.original_link_id, + "source_req_id": row.source_req_id, + "target_req_id": row.target_req_id, + "source_tag_code": row.source_tag_code, + "target_tag_code": row.target_tag_code, + "source_req_name": row.source_req_name, + "target_req_name": row.target_req_name, + "relationship_type_snapshot": row.relationship_type_snapshot, + "inverse_type_snapshot": row.inverse_type_snapshot, + "created_by_username": row.created_by_full_name or row.created_by_username, + "valid_from": row.valid_from, + "valid_to": row.valid_to, + } + for row in rows + ] diff --git a/frontend/src/pages/RequirementDetailPage.tsx b/frontend/src/pages/RequirementDetailPage.tsx index febd230..eb8c61b 100644 --- a/frontend/src/pages/RequirementDetailPage.tsx +++ b/frontend/src/pages/RequirementDetailPage.tsx @@ -12,6 +12,33 @@ import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory, // Tab types type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history' +// Timeline event types for unified history view +type TimelineEventType = 'requirement_edit' | 'link_created' | 'link_removed' | 'group_added' | 'group_removed' + +interface TimelineEvent { + id: string + type: TimelineEventType + date: string + // For requirement edits + historyItem?: RequirementHistory + nextHistoryItem?: RequirementHistory | null // The next version (for diff comparison) + // For link events + linkData?: { + isSource: boolean // Whether current requirement is the source + typeName: string + inverseTypeName: string | null + linkedReqId: number | null + linkedReqName: string | null + linkedTagCode: string | null + createdByUsername: string | null + } + // For group removed events + groupData?: { + groupName: string | null + groupHexColor: string | null + } +} + export default function RequirementDetailPage() { const { user, logout, isAuditor } = useAuth() const { currentProject } = useProject() @@ -83,9 +110,10 @@ export default function RequirementDetailPage() { const [editOptionsLoading, setEditOptionsLoading] = useState(false) // Requirement history state - const [reqHistory, setReqHistory] = useState([]) const [reqHistoryLoading, setReqHistoryLoading] = useState(false) - const [expandedHistoryId, setExpandedHistoryId] = useState(null) + const [expandedHistoryId, setExpandedHistoryId] = useState(null) + const [timelineEvents, setTimelineEvents] = useState([]) + const [showFullDescDiff, setShowFullDescDiff] = useState(null) // Event ID for expanded desc diff // Fetch requirement data on mount useEffect(() => { @@ -184,8 +212,106 @@ export default function RequirementDetailPage() { try { setReqHistoryLoading(true) - const history = await requirementService.getRequirementHistory(parseInt(id, 10)) - setReqHistory(history) + const reqId = parseInt(id, 10) + + // Fetch all five data sources in parallel + const [history, linkHistoryData, currentLinksData, groupHistoryData, currentGroupsData] = await Promise.all([ + requirementService.getRequirementHistory(reqId), + relationshipService.getLinkHistory(reqId), + relationshipService.getRequirementLinks(reqId), + groupService.getGroupHistory(reqId), + groupService.getCurrentGroups(reqId) + ]) + + // Build unified timeline + const events: TimelineEvent[] = [] + + // Add requirement edit events (comparing each version to the next) + history.forEach((item, index) => { + const nextItem = index > 0 ? history[index - 1] : null // Previous in array = newer version + events.push({ + id: `req-${item.history_id}`, + type: 'requirement_edit', + date: item.valid_to || '', + historyItem: item, + nextHistoryItem: nextItem, + }) + }) + + // Add current links as "link_created" events + currentLinksData.forEach((link) => { + const isSource = link.direction === 'outgoing' + events.push({ + id: `link-current-${link.id}`, + type: 'link_created', + date: link.created_at || '', + linkData: { + isSource, + typeName: link.type_name, + inverseTypeName: link.inverse_type_name, + linkedReqId: link.linked_requirement.id, + linkedReqName: link.linked_requirement.req_name, + linkedTagCode: link.linked_requirement.tag_code, + createdByUsername: link.created_by_username, + } + }) + }) + + // Add link history as "link_removed" events + linkHistoryData.forEach((linkHist) => { + const isSource = linkHist.source_req_id === reqId + events.push({ + id: `link-hist-${linkHist.history_id}`, + type: 'link_removed', + date: linkHist.valid_to || '', + linkData: { + isSource, + typeName: isSource + ? (linkHist.relationship_type_snapshot || 'Unknown') + : (linkHist.inverse_type_snapshot || linkHist.relationship_type_snapshot || 'Unknown'), + inverseTypeName: isSource ? linkHist.inverse_type_snapshot : linkHist.relationship_type_snapshot, + linkedReqId: isSource ? linkHist.target_req_id : linkHist.source_req_id, + linkedReqName: isSource ? linkHist.target_req_name : linkHist.source_req_name, + linkedTagCode: isSource ? linkHist.target_tag_code : linkHist.source_tag_code, + createdByUsername: linkHist.created_by_username, + } + }) + }) + + // Add current groups as "group_added" events + currentGroupsData.forEach((group) => { + events.push({ + id: `group-current-${group.group_id}`, + type: 'group_added', + date: group.created_at || '', + groupData: { + groupName: group.group_name, + groupHexColor: group.group_hex_color, + } + }) + }) + + // Add group history as "group_removed" events + groupHistoryData.forEach((groupHist) => { + events.push({ + id: `group-hist-${groupHist.history_id}`, + type: 'group_removed', + date: groupHist.valid_to || '', + groupData: { + groupName: groupHist.group_name_snapshot, + groupHexColor: groupHist.group_hex_color_snapshot, + } + }) + }) + + // Sort by date descending (newest first) + events.sort((a, b) => { + const dateA = a.date ? new Date(a.date).getTime() : 0 + const dateB = b.date ? new Date(b.date).getTime() : 0 + return dateB - dateA + }) + + setTimelineEvents(events) } catch (err) { console.error('Failed to fetch requirement history:', err) } finally { @@ -1150,6 +1276,18 @@ export default function RequirementDetailPage() { ) case 'history': + // Helper function to truncate text + const truncateText = (text: string | null, maxLength: number = 100): string => { + if (!text) return '' + if (text.length <= maxLength) return text + return text.substring(0, maxLength) + '...' + } + + // Helper to check if a field changed between old and new + const hasChange = (oldVal: string | null | undefined, newVal: string | null | undefined): boolean => { + return (oldVal || '') !== (newVal || '') + } + return (

Version History

@@ -1168,93 +1306,335 @@ export default function RequirementDetailPage() { )}
- -
- Note: Group changes are not tracked in the version history. -
{reqHistoryLoading ? (

Loading history...

- ) : reqHistory.length === 0 ? ( -

No version history yet. History is recorded when the requirement is edited.

+ ) : timelineEvents.length === 0 ? ( +

No history yet. History is recorded when the requirement is edited or relationships change.

) : ( -
- {reqHistory.map((historyItem) => ( -
- {/* History Row Header */} -
setExpandedHistoryId( - expandedHistoryId === historyItem.history_id ? null : historyItem.history_id - )} - > -
- - v{historyItem.version} - - - {historyItem.req_name || No name} - - {historyItem.tag_code && ( - - {historyItem.tag_code} - - )} - {historyItem.priority_name && ( - - Priority: {historyItem.priority_name} - - )} -
-
- {historyItem.edited_by_username && ( - by {historyItem.edited_by_username} - )} - {historyItem.valid_from && historyItem.valid_to && ( - - {new Date(historyItem.valid_from).toLocaleDateString()} - {new Date(historyItem.valid_to).toLocaleDateString()} - - )} - - {expandedHistoryId === historyItem.history_id ? '▼' : '▶'} - -
-
+
+ {timelineEvents.map((event) => { + if (event.type === 'requirement_edit' && event.historyItem) { + const oldItem = event.historyItem + const newItem = event.nextHistoryItem + const isExpanded = expandedHistoryId === event.id - {/* Expanded Description */} - {expandedHistoryId === historyItem.history_id && ( -
-

Description:

-
-

- {historyItem.req_desc || No description} -

-
-
-
- Valid From:{' '} - - {historyItem.valid_from - ? new Date(historyItem.valid_from).toLocaleString() - : 'N/A'} + // Determine what changed (compare old version to newer version or current) + const newName = newItem?.req_name ?? requirement.req_name + const newDesc = newItem?.req_desc ?? requirement.req_desc + const newTag = newItem?.tag_code ?? requirement.tag.tag_code + const newPriority = newItem?.priority_name ?? requirement.priority?.priority_name + + const nameChanged = hasChange(oldItem.req_name, newName) + const descChanged = hasChange(oldItem.req_desc, newDesc) + const tagChanged = hasChange(oldItem.tag_code, newTag) + const priorityChanged = hasChange(oldItem.priority_name, newPriority) + const hasAnyChange = nameChanged || descChanged || tagChanged || priorityChanged + + return ( +
+ {/* Header */} +
setExpandedHistoryId(isExpanded ? null : event.id)} + > +
+ 📝 + + v{oldItem.version} → v{newItem ? newItem.version : requirement.version} + + Requirement edited +
+
+ {oldItem.edited_by_username && ( + by {oldItem.edited_by_username} + )} + + {oldItem.valid_to ? new Date(oldItem.valid_to).toLocaleString() : ''} + + + {isExpanded ? '▼' : '▶'}
-
- Valid To:{' '} - - {historyItem.valid_to - ? new Date(historyItem.valid_to).toLocaleString() - : 'N/A'} +
+ + {/* Expanded diff view */} + {isExpanded && ( +
+ {!hasAnyChange && ( +

No visible changes detected (may be a group-only change).

+ )} + + {/* Name diff */} + {nameChanged && ( +
+

Name:

+
+
+ Old +

{oldItem.req_name || Empty}

+
+
+ New +

{newName || Empty}

+
+
+
+ )} + + {/* Tag diff */} + {tagChanged && ( +
+

Tag:

+
+
+ Old +

+ {oldItem.tag_code ? ( + {oldItem.tag_code} + ) : ( + None + )} +

+
+
+ New +

+ {newTag ? ( + {newTag} + ) : ( + None + )} +

+
+
+
+ )} + + {/* Priority diff */} + {priorityChanged && ( +
+

Priority:

+
+
+ Old +

{oldItem.priority_name || None}

+
+
+ New +

{newPriority || None}

+
+
+
+ )} + + {/* Description diff */} + {descChanged && ( +
+

Description:

+
+
+ Old +

+ {showFullDescDiff === event.id + ? (oldItem.req_desc || Empty) + : (truncateText(oldItem.req_desc) || Empty) + } +

+
+
+ New +

+ {showFullDescDiff === event.id + ? (newDesc || Empty) + : (truncateText(newDesc) || Empty) + } +

+
+
+ {((oldItem.req_desc && oldItem.req_desc.length > 100) || (newDesc && newDesc.length > 100)) && ( + + )} +
+ )} +
+ )} +
+ ) + } + + if (event.type === 'link_created' && event.linkData) { + const link = event.linkData + return ( +
+
+
+ 🔗 + Link Created + + {link.isSource ? ( + <> + {link.typeName} + {' → '} + {link.linkedTagCode && ( + + {link.linkedTagCode} + + )} + {link.linkedReqName || 'Unknown Requirement'} + + ) : ( + <> + {link.linkedTagCode && ( + + {link.linkedTagCode} + + )} + {link.linkedReqName || 'Unknown Requirement'} + {' → '} + {link.typeName} + + )} + +
+
+ {link.createdByUsername && ( + by {link.createdByUsername} + )} + + {event.date ? new Date(event.date).toLocaleString() : ''}
- )} -
- ))} + ) + } + + if (event.type === 'link_removed' && event.linkData) { + const link = event.linkData + return ( +
+
+
+ 🔗 + Link Removed + + {link.isSource ? ( + <> + {link.typeName} + {' → '} + {link.linkedTagCode && ( + + {link.linkedTagCode} + + )} + {link.linkedReqName || 'Deleted Requirement'} + + ) : ( + <> + {link.linkedTagCode && ( + + {link.linkedTagCode} + + )} + {link.linkedReqName || 'Deleted Requirement'} + {' → '} + {link.typeName} + + )} + +
+
+ + {event.date ? new Date(event.date).toLocaleString() : ''} + +
+
+
+ ) + } + + if (event.type === 'group_added' && event.groupData) { + const group = event.groupData + return ( +
+
+
+ 🏷️ + Group Added + + {group.groupName ? ( + + {group.groupName} + + ) : ( + Unknown Group + )} + +
+
+ + {event.date ? new Date(event.date).toLocaleString() : ''} + +
+
+
+ ) + } + + if (event.type === 'group_removed' && event.groupData) { + const group = event.groupData + return ( +
+
+
+ 🏷️ + Group Removed + + {group.groupName ? ( + + {group.groupName} + + ) : ( + Unknown Group + )} + +
+
+ + {event.date ? new Date(event.date).toLocaleString() : ''} + +
+
+
+ ) + } + + return null + })}
)}
diff --git a/frontend/src/services/groupService.ts b/frontend/src/services/groupService.ts index 50c12f1..5c91066 100644 --- a/frontend/src/services/groupService.ts +++ b/frontend/src/services/groupService.ts @@ -1,3 +1,5 @@ +import type { RequirementGroupHistory, CurrentRequirementGroup } from '@/types' + const API_BASE_URL = '/api' export interface Group { @@ -31,6 +33,58 @@ class GroupService { throw error } } + + /** + * Get group association history for a requirement. + * Returns records of when groups were removed from the requirement. + */ + async getGroupHistory(requirementId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/groups/history`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const history: RequirementGroupHistory[] = await response.json() + return history + } catch (error) { + console.error('Failed to fetch group history:', error) + throw error + } + } + + /** + * Get current groups for a requirement with their association timestamps. + * Returns records of when groups were added to the requirement. + */ + async getCurrentGroups(requirementId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/groups/current`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const groups: CurrentRequirementGroup[] = await response.json() + return groups + } catch (error) { + console.error('Failed to fetch current groups:', error) + throw error + } + } } export const groupService = new GroupService() diff --git a/frontend/src/services/relationshipService.ts b/frontend/src/services/relationshipService.ts index f5d87d1..87f2a5d 100644 --- a/frontend/src/services/relationshipService.ts +++ b/frontend/src/services/relationshipService.ts @@ -50,6 +50,22 @@ export interface RelationshipTypeUpdateRequest { inverse_type_name?: string | null } +export interface RequirementLinkHistory { + history_id: number + original_link_id: number | null + source_req_id: number | null + target_req_id: number | null + source_tag_code: string | null + target_tag_code: string | null + source_req_name: string | null + target_req_name: string | null + relationship_type_snapshot: string | null + inverse_type_snapshot: string | null + created_by_username: string | null + valid_from: string | null + valid_to: string | null +} + class RelationshipService { /** * Get all relationship types for a project. @@ -234,6 +250,25 @@ class RelationshipService { throw new Error(errorData.detail || `HTTP error! status: ${response.status}`) } } + + /** + * Get the link history for a requirement (deleted/changed links). + */ + async getLinkHistory(requirementId: number): Promise { + const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/links/history`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() + } } export const relationshipService = new RelationshipService() diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6bf45ba..9875bdd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -73,6 +73,43 @@ export interface RequirementHistory { valid_to: string | null } +// Requirement Link History types +export interface RequirementLinkHistory { + history_id: number + original_link_id: number | null + source_req_id: number | null + target_req_id: number | null + source_tag_code: string | null + target_tag_code: string | null + source_req_name: string | null + target_req_name: string | null + relationship_type_snapshot: string | null + inverse_type_snapshot: string | null + created_by_username: string | null + valid_from: string | null // When link was created + valid_to: string | null // When link was deleted +} + +// Requirement Group History types +export interface RequirementGroupHistory { + history_id: number + requirement_id: number + group_id: number + group_name_snapshot: string | null // Group name at time of removal + group_hex_color_snapshot: string | null // Hex color at time of removal + valid_from: string | null // When group was associated + valid_to: string | null // When group was removed +} + +// Current Requirement Group with timestamp +export interface CurrentRequirementGroup { + requirement_id: number + group_id: number + group_name: string + group_hex_color: string + created_at: string | null // When group was added +} + // Deleted Requirement types export interface DeletedRequirement { history_id: number