Skip to main content
Glama
requirement-service.ts35.3 kB
import { v4 as uuidv4 } from 'uuid'; import type { RepositoryFactory } from '../../infrastructure/factory/repository-factory.js'; import type { PlanService } from './plan-service.js'; import type { VersionHistoryService } from './version-history-service.js'; import type { LinkingService } from './linking-service.js'; import type { Requirement, RequirementSource, RequirementPriority, RequirementCategory, RequirementStatus, Tag, VersionHistory, VersionDiff, Solution, } from '../entities/types.js'; import { NotFoundError, ValidationError } from '../repositories/errors.js'; import { validateTags, validateRequiredString, validateRequiredEnum, validateOptionalString, validateListParams, validateFilterPriority, validateFilterCategory, validateFilterStatus, validateTextLength, MAX_TITLE_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_RATIONALE_LENGTH, } from './validators.js'; import { filterEntities, filterEntity } from '../utils/field-filter.js'; // Constants const MAX_REQUIREMENTS_BATCH_SIZE = 100; const DEFAULT_REQUIREMENTS_PAGE_LIMIT = 50; // Input types export interface AddRequirementInput { planId: string; requirement: { title: string; // REQUIRED description?: string; // Optional - default: '' rationale?: string; source?: { // Optional object, but source.type is REQUIRED if source provided type: RequirementSource; // REQUIRED context?: string; parentId?: string; }; acceptanceCriteria?: string[]; // Optional - default: [] priority?: RequirementPriority; // Optional - default: 'medium' category?: RequirementCategory; // Optional - default: 'functional' status?: RequirementStatus; // Optional - default: 'draft' (BUG-013 fix) impact?: { scope: string[]; complexityEstimate: number; riskLevel: 'low' | 'medium' | 'high'; }; tags?: Tag[]; }; } export interface GetRequirementInput { planId: string; requirementId: string; includeTraceability?: boolean; fields?: string[]; // Fields to include: summary (default), ['*'] (all), or custom list excludeMetadata?: boolean; // Exclude metadata fields (createdAt, updatedAt, version, metadata) } export interface UpdateRequirementInput { planId: string; requirementId: string; updates: Partial<{ title: string; description: string; rationale: string; acceptanceCriteria: string[]; priority: RequirementPriority; category: RequirementCategory; status: RequirementStatus; impact: { scope: string[]; complexityEstimate: number; riskLevel: 'low' | 'medium' | 'high'; }; tags: Tag[]; }>; } export interface ListRequirementsInput { planId: string; filters?: { priority?: RequirementPriority; category?: RequirementCategory; status?: RequirementStatus; tags?: Tag[]; }; limit?: number; offset?: number; fields?: string[]; // Fields to include: summary (default), ['*'] (all), or custom list excludeMetadata?: boolean; // Exclude metadata fields (createdAt, updatedAt, version, metadata) } export interface DeleteRequirementInput { planId: string; requirementId: string; force?: boolean; } export interface VoteForRequirementInput { planId: string; requirementId: string; } export interface UnvoteRequirementInput { planId: string; requirementId: string; } export interface ResetAllVotesInput { planId: string; } export interface BulkUpdateRequirementsInput { planId: string; updates: { requirementId: string; updates: Partial<{ title: string; description: string; rationale: string; acceptanceCriteria: string[]; priority: RequirementPriority; category: RequirementCategory; status: RequirementStatus; impact: { scope: string[]; complexityEstimate: number; riskLevel: 'low' | 'medium' | 'high'; }; tags: Tag[]; }>; }[]; atomic?: boolean; // Default: false (non-atomic mode) } // Output types export interface AddRequirementResult { requirementId: string; } export interface GetRequirementResult { requirement: Requirement; traceability?: { solutions: unknown[]; selectedSolution: unknown; implementingPhases: unknown[]; decisions: unknown[]; linkedRequirements: Requirement[]; }; } export interface GetRequirementsInput { planId: string; requirementIds: string[]; fields?: string[]; excludeMetadata?: boolean; } export interface GetRequirementsResult { requirements: Requirement[]; notFound: string[]; } export interface UpdateRequirementResult { success: boolean; requirementId: string; } export interface ListRequirementsResult { requirements: Requirement[]; total: number; hasMore: boolean; } export interface DeleteRequirementResult { success: boolean; message: string; warnings?: string[]; } export interface VoteForRequirementResult { success: boolean; votes: number; } export interface BulkUpdateRequirementsResult { updated: number; failed: number; results: { requirementId: string; success: boolean; error?: string; }[]; } export interface UnvoteRequirementResult { success: boolean; votes: number; } // Sprint 5: Array Field Operations interfaces export type RequirementArrayField = 'acceptanceCriteria'; export interface ArrayAppendInput { planId: string; requirementId: string; field: RequirementArrayField; value: string; } export interface ArrayPrependInput { planId: string; requirementId: string; field: RequirementArrayField; value: string; } export interface ArrayInsertAtInput { planId: string; requirementId: string; field: RequirementArrayField; index: number; value: string; } export interface ArrayUpdateAtInput { planId: string; requirementId: string; field: RequirementArrayField; index: number; value: string; } export interface ArrayRemoveAtInput { planId: string; requirementId: string; field: RequirementArrayField; index: number; } export interface ArrayOperationResult { success: true; field: RequirementArrayField; newLength: number; } // Sprint 7: Version History input/output types export interface GetRequirementHistoryInput { planId: string; requirementId: string; limit?: number; offset?: number; } export interface DiffRequirementInput { planId: string; requirementId: string; version1: number; version2: number; } export interface ResetAllVotesResult { success: boolean; updated: number; } export class RequirementService { constructor( private readonly repositoryFactory: RepositoryFactory, private readonly planService: PlanService, private readonly versionHistoryService?: VersionHistoryService, // Sprint 7: Optional for backward compatibility private readonly linkingService?: LinkingService // REQ-5: Optional for backward compatibility ) {} public async addRequirement(input: AddRequirementInput): Promise<AddRequirementResult> { const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } // Validate REQUIRED fields validateRequiredString(input.requirement.title, 'title'); // Validate source.type is provided if (input.requirement.source === undefined) { throw new Error('source is required'); } validateRequiredEnum( input.requirement.source.type, 'source.type', ['user-request', 'discovered', 'derived'] ); // BUG-011 FIX: Validate parentId for derived requirements if (input.requirement.source.type === 'derived') { if ( input.requirement.source.parentId === undefined || input.requirement.source.parentId === '' ) { throw new ValidationError( 'parentId is required when source.type is "derived"', [ { field: 'source.parentId', message: 'parentId is required when source.type is "derived"', }, ] ); } // Validate UUID format const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!uuidRegex.test(input.requirement.source.parentId)) { throw new ValidationError( 'parentId must be a valid UUID', [ { field: 'source.parentId', message: 'parentId must be a valid UUID', }, ] ); } // Validate parent requirement exists const repo = this.repositoryFactory.createRepository<Requirement>( 'requirement', input.planId ); try { await repo.findById(input.requirement.source.parentId); } catch (error: unknown) { if ( error instanceof NotFoundError || (error instanceof Error && error.constructor.name === 'NotFoundError') ) { throw new ValidationError( `Parent requirement '${input.requirement.source.parentId}' not found`, [ { field: 'source.parentId', message: `Parent requirement '${input.requirement.source.parentId}' not found`, }, ] ); } throw error; } } // BUGS #2, #3: Validate priority and category if provided if (input.requirement.priority !== undefined) { validateRequiredEnum( input.requirement.priority, 'priority', ['critical', 'high', 'medium', 'low'] ); } if (input.requirement.category !== undefined) { validateRequiredEnum( input.requirement.category, 'category', ['functional', 'non-functional', 'technical', 'business'] ); } // BUG-013 FIX: Validate status if provided if (input.requirement.status !== undefined) { validateRequiredEnum( input.requirement.status, 'status', ['draft', 'approved', 'implemented', 'deferred', 'rejected'] ); } // Validate tags format validateTags(input.requirement.tags ?? []); // Validate optional string fields (BUG-003, BUG-029, BUG-042) validateOptionalString(input.requirement.description, 'description'); validateOptionalString(input.requirement.rationale, 'rationale'); // BUG-012 FIX: Validate text length limits validateTextLength(input.requirement.title, 'title', MAX_TITLE_LENGTH); if (input.requirement.description !== undefined) { validateTextLength(input.requirement.description, 'description', MAX_DESCRIPTION_LENGTH); } if (input.requirement.rationale !== undefined) { validateTextLength(input.requirement.rationale, 'rationale', MAX_RATIONALE_LENGTH); } const requirementId = uuidv4(); const now = new Date().toISOString(); const requirement: Requirement = { id: requirementId, type: 'requirement', createdAt: now, updatedAt: now, version: 1, metadata: { createdBy: 'claude-code', tags: input.requirement.tags ?? [], annotations: [], }, title: input.requirement.title, // REQUIRED description: input.requirement.description ?? '', // DEFAULT: empty string rationale: input.requirement.rationale, // undefined OK source: input.requirement.source, // source.type is REQUIRED and validated above acceptanceCriteria: input.requirement.acceptanceCriteria ?? [], // DEFAULT: empty array priority: input.requirement.priority ?? 'medium', // DEFAULT: medium category: input.requirement.category ?? 'functional', // DEFAULT: functional status: input.requirement.status ?? 'draft', // DEFAULT: draft (BUG-013 fix) votes: 0, impact: input.requirement.impact, // undefined OK }; // Create requirement via repository const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); await repo.create(requirement); // Update statistics await this.planService.updateStatistics(input.planId); return { requirementId: requirement.id, }; } public async getRequirement(input: GetRequirementInput): Promise<GetRequirementResult> { const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); const requirement = await repo.findById(input.requirementId); // Apply field filtering - GET operations default to all fields const filtered = filterEntity( requirement, input.fields ?? ['*'], 'requirement', input.excludeMetadata, false ) as Requirement; const result: GetRequirementResult = { requirement: filtered }; if (input.includeTraceability === true) { // TODO: Implement traceability when linking is done result.traceability = { solutions: [], selectedSolution: null, implementingPhases: [], decisions: [], linkedRequirements: [], }; } return result; } public async getRequirements(input: GetRequirementsInput): Promise<GetRequirementsResult> { // Enforce max limit if (input.requirementIds.length > MAX_REQUIREMENTS_BATCH_SIZE) { throw new Error(`Cannot fetch more than ${String(MAX_REQUIREMENTS_BATCH_SIZE)} requirements at once`); } // Handle empty array if (input.requirementIds.length === 0) { return { requirements: [], notFound: [] }; } const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); const foundRequirements: Requirement[] = []; const notFound: string[] = []; // Fetch each requirement by ID for (const id of input.requirementIds) { try { const requirement = await repo.findById(id); // Apply field filtering - requirements default to all fields const filtered = filterEntity( requirement, input.fields ?? ['*'], 'requirement', input.excludeMetadata, false ) as Requirement; foundRequirements.push(filtered); } catch (error: unknown) { // FIX M-1: Only treat NotFoundError as "not found", re-throw other errors if (error instanceof NotFoundError || (error instanceof Error && error.constructor.name === 'NotFoundError')) { notFound.push(id); } else { // Preserve error context and re-throw throw error; } } } return { requirements: foundRequirements, notFound }; } public async updateRequirement( input: UpdateRequirementInput ): Promise<UpdateRequirementResult> { const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); const requirement = await repo.findById(input.requirementId); // Sprint 7: Save current version to history BEFORE updating if (this.versionHistoryService) { // Create a deep copy of the current requirement state const currentSnapshot = JSON.parse(JSON.stringify(requirement)) as Requirement; await this.versionHistoryService.saveVersion( input.planId, input.requirementId, 'requirement', currentSnapshot, requirement.version, // Save with current version number 'claude-code', 'Auto-saved before update' ); } // BUG-021 FIX: Validate readonly fields if ('votes' in input.updates) { throw new ValidationError( 'votes is a read-only field. Use vote/unvote actions instead', [ { field: 'votes', message: 'votes is a read-only field. Use vote/unvote actions instead', }, ] ); } // BUG #18: Validate title if provided in updates if (input.updates.title !== undefined) { validateRequiredString(input.updates.title, 'title'); // BUG-012 FIX: Validate title length validateTextLength(input.updates.title, 'title', MAX_TITLE_LENGTH); requirement.title = input.updates.title; } if (input.updates.description !== undefined) { // M-2 FIX: Validate optional string fields in update path (BUG-003, BUG-029, BUG-042) validateOptionalString(input.updates.description, 'description'); // BUG-012 FIX: Validate description length validateTextLength(input.updates.description, 'description', MAX_DESCRIPTION_LENGTH); requirement.description = input.updates.description; } if (input.updates.rationale !== undefined) { // M-2 FIX: Validate optional string fields in update path (BUG-003, BUG-029, BUG-042) validateOptionalString(input.updates.rationale, 'rationale'); // BUG-012 FIX: Validate rationale length validateTextLength(input.updates.rationale, 'rationale', MAX_RATIONALE_LENGTH); requirement.rationale = input.updates.rationale; } if (input.updates.acceptanceCriteria !== undefined) { requirement.acceptanceCriteria = input.updates.acceptanceCriteria; } // BUG #10: Validate priority and category if provided in updates if (input.updates.priority !== undefined) { validateRequiredEnum( input.updates.priority, 'priority', ['critical', 'high', 'medium', 'low'] ); requirement.priority = input.updates.priority; } if (input.updates.category !== undefined) { validateRequiredEnum( input.updates.category, 'category', ['functional', 'non-functional', 'technical', 'business'] ); requirement.category = input.updates.category; } if (input.updates.status !== undefined) { requirement.status = input.updates.status; } if (input.updates.impact !== undefined) { requirement.impact = input.updates.impact; } if (input.updates.tags !== undefined) { validateTags(input.updates.tags); requirement.metadata.tags = input.updates.tags; } await repo.update(requirement.id, requirement); return { success: true, requirementId: input.requirementId, }; } public async listRequirements( input: ListRequirementsInput ): Promise<ListRequirementsResult> { // BUG-018, BUG-019 FIX: Validate list params validateListParams(input.limit, input.offset); // BUG-022 FIX: Validate filter values if (input.filters !== undefined) { validateFilterPriority(input.filters.priority); validateFilterCategory(input.filters.category); validateFilterStatus(input.filters.status); } const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); // Build query options (currently unused, reserved for future pagination implementation) // eslint-disable-next-line @typescript-eslint/no-unused-vars const queryOptions: { limit: number; offset: number } = { limit: input.limit ?? DEFAULT_REQUIREMENTS_PAGE_LIMIT, offset: input.offset ?? 0, }; // Build filter (Note: Repository pattern may not support complex tag filtering - fallback to in-memory) let requirements = await repo.findAll(); // Apply filters in-memory (TODO: move to repository layer for performance) if (input.filters !== undefined) { const filters = input.filters; if (filters.priority !== undefined) { requirements = requirements.filter( (r) => r.priority === filters.priority ); } if (filters.category !== undefined) { requirements = requirements.filter( (r) => r.category === filters.category ); } if (filters.status !== undefined) { requirements = requirements.filter( (r) => r.status === filters.status ); } if (filters.tags && filters.tags.length > 0) { const filterTags = filters.tags; requirements = requirements.filter((r) => filterTags.some((filterTag) => r.metadata.tags.some( (t) => t.key === filterTag.key && t.value === filterTag.value ) ) ); } } // Pagination const total = requirements.length; const offset = input.offset ?? 0; const limit = input.limit ?? DEFAULT_REQUIREMENTS_PAGE_LIMIT; const paginated = requirements.slice(offset, offset + limit); // Apply field filtering const filtered = filterEntities( paginated, input.fields, 'requirement', input.excludeMetadata, false ) as Requirement[]; return { requirements: filtered, total, hasMore: offset + limit < total, }; } public async deleteRequirement( input: DeleteRequirementInput ): Promise<DeleteRequirementResult> { const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); // Verify exists (throws NotFoundError if not found) await repo.findById(input.requirementId); // REQ-5: Cascade delete all links for this requirement if (this.linkingService) { await this.linkingService.deleteLinksForEntity(input.planId, input.requirementId); } // REQ-5: Clean solution.addressing arrays that reference this requirement await this.cleanSolutionAddressing(input.planId, input.requirementId); // Delete the requirement await repo.delete(input.requirementId); // Update statistics await this.planService.updateStatistics(input.planId); return { success: true, message: 'Requirement deleted', }; } /** * REQ-5: Remove deleted requirement ID from all solution.addressing arrays */ private async cleanSolutionAddressing(planId: string, requirementId: string): Promise<void> { const solutionRepo = this.repositoryFactory.createRepository<Solution>('solution', planId); const allSolutions = await solutionRepo.findAll(); // Find solutions that reference this requirement const toUpdate = allSolutions.filter((sol) => sol.addressing.includes(requirementId)); // Remove requirement ID from addressing arrays for (const solution of toUpdate) { solution.addressing = solution.addressing.filter((id) => id !== requirementId); await solutionRepo.update(solution.id, solution); } } public async voteForRequirement( input: VoteForRequirementInput ): Promise<VoteForRequirementResult> { const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); const requirement = await repo.findById(input.requirementId); // Initialize votes if undefined (backward compatibility) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition requirement.votes ??= 0; // Increment votes requirement.votes += 1; await repo.update(requirement.id, requirement); return { success: true, votes: requirement.votes, }; } public async unvoteRequirement( input: UnvoteRequirementInput ): Promise<UnvoteRequirementResult> { const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); const requirement = await repo.findById(input.requirementId); // Initialize votes if undefined (backward compatibility) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition requirement.votes ??= 0; // Validate: cannot go below 0 if (requirement.votes <= 0) { throw new Error('Cannot unvote: votes cannot be negative'); } // Decrement votes requirement.votes -= 1; await repo.update(requirement.id, requirement); return { success: true, votes: requirement.votes, }; } /** * Sprint 5: Array Field Operations * Validate that field is a valid array field for Requirement */ private validateArrayField(field: string): asserts field is RequirementArrayField { const validFields: RequirementArrayField[] = ['acceptanceCriteria']; if (!validFields.includes(field as RequirementArrayField)) { throw new Error(`Field ${field} is not a valid array field. Valid fields: ${validFields.join(', ')}`); } } /** * Execute an array operation with common load/save logic * @param planId - Plan identifier * @param requirementId - Requirement identifier * @param field - Array field to modify * @param operation - Function that transforms the current array to new array * @returns Operation result with success status and new array length */ private async executeArrayOperation( planId: string, requirementId: string, field: RequirementArrayField, operation: (currentArray: string[]) => string[] ): Promise<ArrayOperationResult> { const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(planId); if (!exists) { throw new Error('Plan not found'); } const repo = this.repositoryFactory.createRepository<Requirement>('requirement', planId); const requirement = await repo.findById(requirementId); const currentArray = requirement[field]; const newArray = operation(currentArray); requirement[field] = newArray; await repo.update(requirement.id, requirement); return { success: true, field, newLength: newArray.length, }; } /** * Append item to end of array field */ public async arrayAppend(input: ArrayAppendInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.requirementId, input.field, (currentArray) => [...currentArray, input.value] ); } /** * Prepend item to beginning of array field */ public async arrayPrepend(input: ArrayPrependInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.requirementId, input.field, (currentArray) => [input.value, ...currentArray] ); } /** * Insert item at specific index in array field */ public async arrayInsertAt(input: ArrayInsertAtInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.requirementId, input.field, (currentArray) => { if (input.index < 0 || input.index > currentArray.length) { throw new Error(`Index ${String(input.index)} is out of bounds for array of length ${String(currentArray.length)}`); } const newArray = [...currentArray]; newArray.splice(input.index, 0, input.value); return newArray; } ); } /** * Update item at specific index in array field */ public async arrayUpdateAt(input: ArrayUpdateAtInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.requirementId, input.field, (currentArray) => { if (input.index < 0 || input.index >= currentArray.length) { throw new Error(`Index ${String(input.index)} is out of bounds for array of length ${String(currentArray.length)}`); } const newArray = [...currentArray]; newArray[input.index] = input.value; return newArray; } ); } /** * Remove item at specific index in array field */ public async arrayRemoveAt(input: ArrayRemoveAtInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.requirementId, input.field, (currentArray) => { if (input.index < 0 || input.index >= currentArray.length) { throw new Error(`Index ${String(input.index)} is out of bounds for array of length ${String(currentArray.length)}`); } const newArray = [...currentArray]; newArray.splice(input.index, 1); return newArray; } ); } /** * Sprint 7: Get version history for a requirement * Note: Can retrieve history even for deleted requirements */ public async getHistory(input: GetRequirementHistoryInput): Promise<VersionHistory<Requirement>> { if (!this.versionHistoryService) { throw new Error('Version history service not available'); } const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } // REQ-6: Load current entity to get accurate currentVersion const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); let currentVersion = 1; try { const currentRequirement = await repo.findById(input.requirementId); currentVersion = currentRequirement.version; } catch { // Entity might be deleted - use version from history file } const history = await this.versionHistoryService.getHistory({ planId: input.planId, entityId: input.requirementId, entityType: 'requirement', limit: input.limit, offset: input.offset, }); // REQ-6: Override currentVersion with actual entity version history.currentVersion = currentVersion; return history as VersionHistory<Requirement>; } /** * Sprint 7: Compare two versions of a requirement */ public async diff(input: DiffRequirementInput): Promise<VersionDiff> { if (!this.versionHistoryService) { throw new Error('Version history service not available'); } const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } // Load current requirement to support diffing with current version const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); const currentRequirement = await repo.findById(input.requirementId); return this.versionHistoryService.diff({ planId: input.planId, entityId: input.requirementId, entityType: 'requirement', version1: input.version1, version2: input.version2, currentEntityData: currentRequirement, currentVersion: currentRequirement.version, }); } /** * Sprint 9: Bulk update multiple requirements in one call * REFACTORED: Uses common bulkUpdateEntities utility */ public async bulkUpdateRequirements( input: BulkUpdateRequirementsInput ): Promise<BulkUpdateRequirementsResult> { const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); if (input.atomic === true) { // ATOMIC MODE: All-or-nothing with true rollback // Phase 1: Load all entities and validate const toUpdate: Requirement[] = []; const results: { requirementId: string; success: boolean; error?: string }[] = []; for (const update of input.updates) { try { // Load requirement (create deep copy to avoid mutations until save) const originalRequirement = await repo.findById(update.requirementId); const requirement = JSON.parse(JSON.stringify(originalRequirement)) as Requirement; // Save original to history before update if (this.versionHistoryService) { await this.versionHistoryService.saveVersion( input.planId, update.requirementId, 'requirement', originalRequirement, originalRequirement.version, 'claude-code', 'Auto-saved before bulk update' ); } // Apply updates with validation if (update.updates.title !== undefined) requirement.title = update.updates.title; if (update.updates.description !== undefined) requirement.description = update.updates.description; if (update.updates.rationale !== undefined) requirement.rationale = update.updates.rationale; if (update.updates.acceptanceCriteria !== undefined) requirement.acceptanceCriteria = update.updates.acceptanceCriteria; if (update.updates.priority !== undefined) requirement.priority = update.updates.priority; if (update.updates.category !== undefined) requirement.category = update.updates.category; if (update.updates.status !== undefined) requirement.status = update.updates.status; if (update.updates.impact !== undefined) requirement.impact = update.updates.impact; if (update.updates.tags !== undefined) { validateTags(update.updates.tags); requirement.metadata.tags = update.updates.tags; } requirement.updatedAt = new Date().toISOString(); requirement.version = requirement.version + 1; toUpdate.push(requirement); results.push({ requirementId: update.requirementId, success: true }); } catch (error: unknown) { // In atomic mode, any error causes full rejection (no partial updates) const message = error instanceof Error ? error.message : String(error); throw new Error(`Atomic bulk update failed: ${message}`); } } // Phase 2: All validations passed - save atomically await repo.upsertMany(toUpdate); return { updated: toUpdate.length, failed: 0, results, }; } else { // NON-ATOMIC MODE: Continue on errors (partial success allowed) const results: { requirementId: string; success: boolean; error?: string }[] = []; let updated = 0; let failed = 0; for (const update of input.updates) { try { await this.updateRequirement({ planId: input.planId, requirementId: update.requirementId, updates: update.updates, }); results.push({ requirementId: update.requirementId, success: true, }); updated++; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); results.push({ requirementId: update.requirementId, success: false, error: message, }); failed++; } } return { updated, failed, results, }; } } public async resetAllVotes( input: ResetAllVotesInput ): Promise<ResetAllVotesResult> { const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } const repo = this.repositoryFactory.createRepository<Requirement>('requirement', input.planId); const requirements = await repo.findAll(); let updated = 0; // Reset votes for all requirements for (const requirement of requirements) { // Check if votes need to be updated (non-zero) const needsUpdate = requirement.votes !== 0; if (needsUpdate) { requirement.votes = 0; await repo.update(requirement.id, requirement); updated++; } } return { success: true, updated, }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server