From 55075cddc638158e64f2e9443381b6ca9a7c925a Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 4 Sep 2025 23:20:29 +0200 Subject: [PATCH] feat: allow non-owners to propose project changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this patch, only project owners could edit project information, preventing community contributions to improve project data accuracy. This is a problem because users who want to help improve project information or suggest corrections cannot contribute, limiting the collaborative potential of the platform. This patch solves the problem by implementing a proposal system where authenticated users can suggest changes to any project. When a non-owner makes edits, changes are saved as proposal records in their own repository under the org.impactindexer.proposal collection, while owner edits continue to update the original project directly. The UI provides clear indicators showing when changes are proposals versus direct edits, and success messages differentiate between the two modes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/project-status/route.ts | 201 ++++++++++++++++++++++---------- app/project/[id]/page.tsx | 65 ++++++++--- 2 files changed, 189 insertions(+), 77 deletions(-) diff --git a/app/api/project-status/route.ts b/app/api/project-status/route.ts index d33beae..f657794 100644 --- a/app/api/project-status/route.ts +++ b/app/api/project-status/route.ts @@ -24,6 +24,24 @@ interface ImpactIndexerStatus { updatedAt?: string } +interface ImpactIndexerProposal { + $type: 'org.impactindexer.proposal' + targetDid: string + displayName?: string + description?: string + website?: string + fundingReceived?: number + fundingGivenOut?: number + annualBudget?: number + teamSize?: number + sustainableRevenuePercent?: number + categories?: string[] + impactMetrics?: string + geographicDistribution?: string + createdAt: string + updatedAt?: string +} + async function getSessionAgent(): Promise { try { const session = await getSession() @@ -127,82 +145,137 @@ export async function POST(request: NextRequest) { } const body = await request.json() + const { targetDid, isProposal, ...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) - // Check if there's an existing record - let existingRecord = null - let rkey = TID.nextStr() + const now = new Date().toISOString() - try { - const listResponse = await agent.com.atproto.repo.listRecords({ - repo: agent.assertDid, - collection: 'org.impactindexer.status', - limit: 1 - }) - - if (listResponse.data.records.length > 0) { - existingRecord = listResponse.data.records[0] - // Extract rkey from URI for updates - rkey = existingRecord.uri.split('/').pop() || TID.nextStr() + if (isProposal && targetDid) { + // 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, + displayName: recordData.displayName, + description: recordData.description, + website: recordData.website, + fundingReceived: recordData.fundingReceived, + fundingGivenOut: recordData.fundingGivenOut, + annualBudget: recordData.annualBudget, + teamSize: recordData.teamSize, + sustainableRevenuePercent: recordData.sustainableRevenuePercent, + categories: recordData.categories, + impactMetrics: recordData.impactMetrics ? JSON.stringify(recordData.impactMetrics) : undefined, + geographicDistribution: recordData.geographicDistribution ? JSON.stringify(recordData.geographicDistribution) : undefined, + createdAt: now, + updatedAt: now, } - } catch (error) { - console.log('No existing record found, will create new one') - } - - // Create the record - const now = new Date().toISOString() - const record = { - $type: 'org.impactindexer.status', - displayName: body.displayName, - description: body.description, - website: body.website, - fundingReceived: body.fundingReceived, - fundingGivenOut: body.fundingGivenOut, - annualBudget: body.annualBudget, - teamSize: body.teamSize, - sustainableRevenuePercent: body.sustainableRevenuePercent, - categories: body.categories, - impactMetrics: body.impactMetrics ? JSON.stringify(body.impactMetrics) : undefined, - geographicDistribution: body.geographicDistribution ? JSON.stringify(body.geographicDistribution) : undefined, - createdAt: (existingRecord?.value as any)?.createdAt || now, - updatedAt: now, - } - console.log('Debug - Final record to save:', record) - console.log('Debug - Website in final record:', record.website) + // Remove undefined fields + Object.keys(proposalRecord).forEach(key => { + if ((proposalRecord as any)[key] === undefined) { + delete (proposalRecord as any)[key] + } + }) - // Remove undefined fields - Object.keys(record).forEach(key => { - if ((record as any)[key] === undefined) { - delete (record as any)[key] + try { + const rkey = TID.nextStr() + const response = await agent.com.atproto.repo.putRecord({ + repo: agent.assertDid, + collection: 'org.impactindexer.proposal', + rkey, + record: proposalRecord, + validate: false, + }) + + return NextResponse.json({ + success: true, + isProposal: true, + uri: response.data.uri, + cid: response.data.cid + }) + } catch (error) { + console.error('Failed to write proposal record:', error) + return NextResponse.json({ error: 'Failed to save proposal' }, { status: 500 }) + } + } else { + // This is a direct update to the user's own project + // Check if there's an existing record + let existingRecord = null + let rkey = TID.nextStr() + + try { + const listResponse = await agent.com.atproto.repo.listRecords({ + repo: agent.assertDid, + collection: 'org.impactindexer.status', + limit: 1 + }) + + if (listResponse.data.records.length > 0) { + existingRecord = listResponse.data.records[0] + // Extract rkey from URI for updates + rkey = existingRecord.uri.split('/').pop() || TID.nextStr() + } + } catch (error) { + console.log('No existing record found, will create new one') + } + + // Create the record + const record = { + $type: 'org.impactindexer.status', + displayName: recordData.displayName, + description: recordData.description, + website: recordData.website, + fundingReceived: recordData.fundingReceived, + fundingGivenOut: recordData.fundingGivenOut, + annualBudget: recordData.annualBudget, + teamSize: recordData.teamSize, + sustainableRevenuePercent: recordData.sustainableRevenuePercent, + categories: recordData.categories, + impactMetrics: recordData.impactMetrics ? JSON.stringify(recordData.impactMetrics) : undefined, + geographicDistribution: recordData.geographicDistribution ? JSON.stringify(recordData.geographicDistribution) : undefined, + createdAt: (existingRecord?.value as any)?.createdAt || now, + updatedAt: now, } - }) - // Basic validation - if (!record.createdAt) { - return NextResponse.json({ error: 'Invalid project status data: createdAt required' }, { status: 400 }) - } + console.log('Debug - Final record to save:', record) + console.log('Debug - Website in final record:', record.website) - // Save the record to ATProto - try { - const response = await agent.com.atproto.repo.putRecord({ - repo: agent.assertDid, - collection: 'org.impactindexer.status', - rkey, - record, - validate: false, - }) - - return NextResponse.json({ - success: true, - uri: response.data.uri, - cid: response.data.cid + // Remove undefined fields + Object.keys(record).forEach(key => { + if ((record as any)[key] === undefined) { + delete (record as any)[key] + } }) - } catch (error) { - console.error('Failed to write project status record:', error) - return NextResponse.json({ error: 'Failed to save project status' }, { status: 500 }) + + // Basic validation + if (!record.createdAt) { + return NextResponse.json({ error: 'Invalid project status data: createdAt required' }, { status: 400 }) + } + + // Save the record to ATProto + try { + const response = await agent.com.atproto.repo.putRecord({ + repo: agent.assertDid, + collection: 'org.impactindexer.status', + rkey, + record, + validate: false, + }) + + return NextResponse.json({ + success: true, + isProposal: false, + uri: response.data.uri, + cid: response.data.cid + }) + } catch (error) { + console.error('Failed to write project status record:', error) + return NextResponse.json({ error: 'Failed to save project status' }, { status: 500 }) + } } } catch (error) { console.error('Project status update failed:', error) diff --git a/app/project/[id]/page.tsx b/app/project/[id]/page.tsx index 2814577..81f2d3b 100644 --- a/app/project/[id]/page.tsx +++ b/app/project/[id]/page.tsx @@ -29,7 +29,11 @@ export default function ProjectPage() { // Check if this is the user's own project const isOwnProject = isDID && profile?.isOwner + // Check if user is authenticated and can make proposals + const canEdit = isDID && profile !== null + const isProposal = isDID && !profile?.isOwner && profile !== null console.log('Debug - isOwnProject:', isOwnProject, 'isDID:', isDID, 'profile?.isOwner:', profile?.isOwner) + console.log('Debug - canEdit:', canEdit, 'isProposal:', isProposal) // Check if we have public/fallback data const hasPublicData = statusMessage && statusMessage.includes('public data') @@ -104,7 +108,7 @@ export default function ProjectPage() { } const handleSave = async (field: string) => { - if (!profile?.isOwner) { + if (!canEdit) { console.error('Not authorized to edit this profile') return } @@ -128,6 +132,9 @@ export default function ProjectPage() { (projectStatus?.categories || []), impactMetrics: projectStatus?.impactMetrics || [], geographicDistribution: projectStatus?.geographicDistribution || [], + // Add proposal-specific parameters + isProposal, + targetDid: isProposal ? id : undefined, } const response = await fetch('/api/project-status', { @@ -139,15 +146,21 @@ export default function ProjectPage() { }) if (response.ok) { - // Update local state - setProjectStatus({...projectStatus, ...updatedData}) + 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.') + } else { + // Handle direct update success + setProjectStatus({...projectStatus, ...updatedData}) + } // Clear temporary input values setEditValues({ ...editValues, categoriesInput: undefined }) setEditingField(null) - console.log('Successfully saved', field) + console.log('Successfully saved', field, responseData.isProposal ? 'as proposal' : 'as direct update') } else { const errorData = await response.json() console.error('Save failed:', errorData.error) @@ -269,6 +282,24 @@ export default function ProjectPage() { )} + {/* Proposal Status Message */} + {isProposal && ( +
+
+
+ + + +
+
+

+ You're viewing someone else's project. Any changes you make will be saved as proposals to your own repository for the project owner to review. +

+
+
+
+ )} + {/* Project Header */}
@@ -295,6 +326,7 @@ export default function ProjectPage() { onClick={() => handleSave('name')} disabled={saving} className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" + title={isProposal ? "Submit proposal" : "Save changes"} > @@ -310,10 +342,11 @@ export default function ProjectPage() {

{editValues.name || project?.name || `${displayName}'s project`}

- {isOwnProject && ( + {canEdit && ( @@ -338,6 +371,7 @@ export default function ProjectPage() { onClick={() => handleSave('description')} disabled={saving} className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" + title={isProposal ? "Submit proposal" : "Save changes"} > @@ -352,12 +386,13 @@ export default function ProjectPage() { ) : (

- {editValues.description || project?.description || (isOwnProject ? 'Click to add a description of your environmental project...' : 'No project details have been added yet.')} + {editValues.description || project?.description || (canEdit ? (isProposal ? 'Click to propose a description for this environmental project...' : 'Click to add a description of your environmental project...') : 'No project details have been added yet.')}

- {isOwnProject && ( + {canEdit && ( @@ -382,6 +417,7 @@ export default function ProjectPage() { onClick={() => handleSave('website')} disabled={saving} className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" + title={isProposal ? "Submit proposal" : "Save changes"} > @@ -405,15 +441,16 @@ export default function ProjectPage() { ) : ( -
+
- {isOwnProject ? 'Click to add website' : 'Website not specified'} + {canEdit ? (isProposal ? 'Click to propose website' : 'Click to add website') : 'Website not specified'}
)} - {isOwnProject && ( + {canEdit && ( @@ -512,6 +549,7 @@ export default function ProjectPage() { onClick={() => handleSave('categories')} disabled={saving} className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" + title={isProposal ? "Submit proposal" : "Save changes"} > @@ -536,16 +574,17 @@ export default function ProjectPage() { )) ) : ( - {isOwnProject ? '+ Add categories' : 'No categories set'} + {canEdit ? (isProposal ? '+ Propose categories' : '+ Add categories') : 'No categories set'} )} - {isOwnProject && ( + {canEdit && ( )}
-- 2.43.0