From 5c1f3108e28f2f0b922391cbf8f291ebe960a393 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 4 Sep 2025 23:56:46 +0200 Subject: [PATCH] feat: implement structured change request system with reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this patch, change requests were handled as simple "proposals" without structured reason collection or proper record management. The system lacked clear separation between the proposed changes and the change request metadata. This is a problem because project owners need context about why changes are being suggested, and the system needs better organization of change request records for future review workflows. This patch solves the problem by: - Replacing ImpactIndexerProposal with ImpactIndexerChangeRequest schema - Adding mandatory reason field for all change requests - Implementing dual record system: proposed status + change request metadata - Adding reason collection modal in frontend with 200-char limit - Fetching original record URI for proper change tracking - Improving error handling and user feedback messages Key changes: - Updated API route to create both proposed status and change request records - Added frontend modal for reason collection before submission - Renamed "proposals" to "change requests" throughout the UI - Added proper validation requiring reasons for all change requests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/project-status/route.ts | 114 ++++++++++++++++++++++-------- app/project/[id]/page.tsx | 120 ++++++++++++++++++++++++++------ 2 files changed, 184 insertions(+), 50 deletions(-) diff --git a/app/api/project-status/route.ts b/app/api/project-status/route.ts index f657794..b073332 100644 --- a/app/api/project-status/route.ts +++ b/app/api/project-status/route.ts @@ -24,20 +24,12 @@ interface ImpactIndexerStatus { updatedAt?: string } -interface ImpactIndexerProposal { - $type: 'org.impactindexer.proposal' +interface ImpactIndexerChangeRequest { + $type: 'org.impactindexer.changeRequest' targetDid: string - displayName?: string - description?: string - website?: string - fundingReceived?: number - fundingGivenOut?: number - annualBudget?: number - teamSize?: number - sustainableRevenuePercent?: number - categories?: string[] - impactMetrics?: string - geographicDistribution?: string + originalRecord?: string + proposedRecord: string + reason: string createdAt: string updatedAt?: string } @@ -145,20 +137,49 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { targetDid, isProposal, ...recordData } = body + const { targetDid, isProposal, reason, ...recordData } = body console.log('Debug - POST request body:', body) console.log('Debug - Website in request body:', body.website) - console.log('Debug - isProposal:', isProposal, 'targetDid:', targetDid) + console.log('Debug - isProposal:', isProposal, 'targetDid:', targetDid, 'reason:', reason) const now = new Date().toISOString() if (isProposal && targetDid) { + if (!reason) { + return NextResponse.json({ error: 'Reason is required for proposals' }, { status: 400 }) + } + + // Get the original record URI for reference + let originalRecordUri = null + try { + // Try to fetch the original record to get its URI + const resolver = new IdResolver() + const didDoc = await resolver.did.resolve(targetDid) + + if (didDoc) { + const pdsEndpoint = getPdsEndpoint(didDoc) + if (pdsEndpoint) { + const publicAgent = new Agent({ service: pdsEndpoint }) + const response = await publicAgent.com.atproto.repo.listRecords({ + repo: targetDid, + collection: 'org.impactindexer.status', + limit: 1 + }) + + if (response.data.records.length > 0) { + originalRecordUri = response.data.records[0].uri + } + } + } + } catch (error) { + console.log('Could not fetch original record:', error) + // Continue without original record URI + } // This is a proposal for someone else's project - // Save as a proposal record in the current user's repo - const proposalRecord = { - $type: 'org.impactindexer.proposal', - targetDid, + // Save as a status record in the current user's repo (the proposed changes) + const proposedStatusRecord: ImpactIndexerStatus = { + $type: 'org.impactindexer.status', displayName: recordData.displayName, description: recordData.description, website: recordData.website, @@ -175,30 +196,63 @@ export async function POST(request: NextRequest) { } // Remove undefined fields - Object.keys(proposalRecord).forEach(key => { - if ((proposalRecord as any)[key] === undefined) { - delete (proposalRecord as any)[key] + Object.keys(proposedStatusRecord).forEach(key => { + if ((proposedStatusRecord as any)[key] === undefined) { + delete (proposedStatusRecord as any)[key] } }) try { - const rkey = TID.nextStr() - const response = await agent.com.atproto.repo.putRecord({ + // Create both status record (the proposal) and change request record + const proposedStatusRkey = TID.nextStr() + const changeRequestRkey = TID.nextStr() + + // Save proposed status record + const proposedStatusResponse = await agent.com.atproto.repo.putRecord({ repo: agent.assertDid, - collection: 'org.impactindexer.proposal', - rkey, - record: proposalRecord, + collection: 'org.impactindexer.status', + rkey: proposedStatusRkey, + record: proposedStatusRecord, + validate: false, + }) + + // Create change request record that references both the original and proposed records + const changeRequestRecord: ImpactIndexerChangeRequest = { + $type: 'org.impactindexer.changeRequest', + targetDid, + originalRecord: originalRecordUri || undefined, + proposedRecord: proposedStatusResponse.data.uri, + reason, + createdAt: now, + updatedAt: now, + } + + // Remove undefined fields from main record + Object.keys(changeRequestRecord).forEach(key => { + if ((changeRequestRecord as any)[key] === undefined) { + delete (changeRequestRecord as any)[key] + } + }) + + // Save change request record + const changeRequestResponse = await agent.com.atproto.repo.putRecord({ + repo: agent.assertDid, + collection: 'org.impactindexer.changeRequest', + rkey: changeRequestRkey, + record: changeRequestRecord, validate: false, }) return NextResponse.json({ success: true, isProposal: true, - uri: response.data.uri, - cid: response.data.cid + proposedStatusUri: proposedStatusResponse.data.uri, + proposedStatusCid: proposedStatusResponse.data.cid, + changeRequestUri: changeRequestResponse.data.uri, + changeRequestCid: changeRequestResponse.data.cid }) } catch (error) { - console.error('Failed to write proposal record:', error) + console.error('Failed to write proposed status/change request records:', error) return NextResponse.json({ error: 'Failed to save proposal' }, { status: 500 }) } } else { diff --git a/app/project/[id]/page.tsx b/app/project/[id]/page.tsx index 81f2d3b..96d0450 100644 --- a/app/project/[id]/page.tsx +++ b/app/project/[id]/page.tsx @@ -20,6 +20,9 @@ export default function ProjectPage() { const [copiedDID, setCopiedDID] = useState(false) const [saving, setSaving] = useState(false) const [statusMessage, setStatusMessage] = useState('') + const [showReasonModal, setShowReasonModal] = useState(false) + const [pendingSave, setPendingSave] = useState<{field: string, data: any} | null>(null) + const [changeReason, setChangeReason] = useState('') // Check if this is a DID const isDID = id.startsWith('did:plc:') @@ -113,28 +116,44 @@ export default function ProjectPage() { return } + // Prepare the data to save - merge existing data with new field value + const updatedData = { + displayName: field === 'name' ? editValues[field] : (projectStatus?.displayName || ''), + description: field === 'description' ? editValues[field] : (projectStatus?.description || ''), + website: field === 'website' ? editValues[field] : (projectStatus?.website || ''), + fundingReceived: projectStatus?.fundingReceived || 0, + fundingGivenOut: projectStatus?.fundingGivenOut || 0, + annualBudget: projectStatus?.annualBudget || 0, + teamSize: projectStatus?.teamSize || 0, + sustainableRevenuePercent: projectStatus?.sustainableRevenuePercent || 0, + categories: field === 'categories' ? + (editValues.categoriesInput ? + editValues.categoriesInput.split(',').map((c: string) => c.trim()).filter((c: string) => c.length > 0) : + editValues[field] || []) : + (projectStatus?.categories || []), + impactMetrics: projectStatus?.impactMetrics || [], + geographicDistribution: projectStatus?.geographicDistribution || [], + } + + if (isProposal) { + // Show reason modal for proposals + setPendingSave({ field, data: updatedData }) + setShowReasonModal(true) + return + } + + // Direct save for owners + await performSave(field, updatedData, null) + } + + const performSave = async (field: string, updatedData: any, reason: string | null) => { setSaving(true) try { - // Prepare the data to save - merge existing data with new field value - const updatedData = { - displayName: field === 'name' ? editValues[field] : (projectStatus?.displayName || ''), - description: field === 'description' ? editValues[field] : (projectStatus?.description || ''), - website: field === 'website' ? editValues[field] : (projectStatus?.website || ''), - fundingReceived: projectStatus?.fundingReceived || 0, - fundingGivenOut: projectStatus?.fundingGivenOut || 0, - annualBudget: projectStatus?.annualBudget || 0, - teamSize: projectStatus?.teamSize || 0, - sustainableRevenuePercent: projectStatus?.sustainableRevenuePercent || 0, - categories: field === 'categories' ? - (editValues.categoriesInput ? - editValues.categoriesInput.split(',').map((c: string) => c.trim()).filter((c: string) => c.length > 0) : - editValues[field] || []) : - (projectStatus?.categories || []), - impactMetrics: projectStatus?.impactMetrics || [], - geographicDistribution: projectStatus?.geographicDistribution || [], - // Add proposal-specific parameters + const requestData = { + ...updatedData, isProposal, targetDid: isProposal ? id : undefined, + reason: reason || undefined, } const response = await fetch('/api/project-status', { @@ -142,14 +161,14 @@ export default function ProjectPage() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(updatedData), + body: JSON.stringify(requestData), }) if (response.ok) { const responseData = await response.json() if (responseData.isProposal) { // Handle proposal success - alert('Proposal submitted successfully! The project owner will be able to review your suggested changes.') + alert('Change request submitted successfully! The project owner will be able to review your suggested changes.') } else { // Handle direct update success setProjectStatus({...projectStatus, ...updatedData}) @@ -174,6 +193,26 @@ export default function ProjectPage() { } } + const handleReasonSubmit = async () => { + if (!changeReason.trim()) { + alert('Please provide a reason for your change request.') + return + } + + if (pendingSave) { + await performSave(pendingSave.field, pendingSave.data, changeReason) + setShowReasonModal(false) + setPendingSave(null) + setChangeReason('') + } + } + + const handleReasonCancel = () => { + setShowReasonModal(false) + setPendingSave(null) + setChangeReason('') + } + const handleCancel = () => { // Clear any temporary input values setEditValues({ @@ -761,6 +800,47 @@ export default function ProjectPage() { + {/* Reason Modal for Proposals */} + {showReasonModal && ( +
+
+

+ Reason for Change Request +

+

+ Please provide a brief explanation for why you're suggesting this change. This will help the project owner understand and review your request. +

+