feat: allow non-owners to propose project changes #1

merged
opened by adamspiers.org targeting main from adamspiers.org/eii-frontend: change-requests

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

Co-Authored-By: Claude noreply@anthropic.com

Changed files
+189 -77
app
api
project-status
project
[id]
+137 -64
app/api/project-status/route.ts
···
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<Agent | null> {
try {
const session = await getSession()
···
}
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)
+52 -13
app/project/[id]/page.tsx
···
// 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')
···
}
const handleSave = async (field: string) => {
-
if (!profile?.isOwner) {
+
if (!canEdit) {
console.error('Not authorized to edit this profile')
return
}
···
(projectStatus?.categories || []),
impactMetrics: projectStatus?.impactMetrics || [],
geographicDistribution: projectStatus?.geographicDistribution || [],
+
// Add proposal-specific parameters
+
isProposal,
+
targetDid: isProposal ? id : undefined,
}
const response = await fetch('/api/project-status', {
···
})
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)
···
</div>
)}
+
{/* Proposal Status Message */}
+
{isProposal && (
+
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
+
<div className="flex items-start">
+
<div className="flex-shrink-0">
+
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
+
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
+
</svg>
+
</div>
+
<div className="ml-3">
+
<p className="text-sm text-amber-700 dark:text-amber-300">
+
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.
+
</p>
+
</div>
+
</div>
+
</div>
+
)}
+
{/* Project Header */}
<div className="bg-surface border-subtle shadow-card p-4 sm:p-6 group">
<div className="flex flex-col sm:flex-row sm:items-start justify-between mb-4 sm:mb-6 gap-4">
···
onClick={() => handleSave('name')}
disabled={saving}
className="p-1 text-accent hover:text-accent-hover disabled:opacity-50"
+
title={isProposal ? "Submit proposal" : "Save changes"}
>
<Save className="h-4 w-4" />
</button>
···
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-primary">
{editValues.name || project?.name || `${displayName}'s project`}
</h1>
-
{isOwnProject && (
+
{canEdit && (
<button
onClick={() => handleEdit('name')}
className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity"
+
title={isProposal ? "Propose change" : "Edit"}
>
<Edit2 className="h-4 w-4" />
</button>
···
onClick={() => handleSave('description')}
disabled={saving}
className="p-1 text-accent hover:text-accent-hover disabled:opacity-50"
+
title={isProposal ? "Submit proposal" : "Save changes"}
>
<Save className="h-4 w-4" />
</button>
···
) : (
<div className="flex items-start gap-2">
<p className="text-sm sm:text-lg text-secondary leading-relaxed flex-1">
-
{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.')}
</p>
-
{isOwnProject && (
+
{canEdit && (
<button
onClick={() => handleEdit('description')}
className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity"
+
title={isProposal ? "Propose change" : "Edit"}
>
<Edit2 className="h-4 w-4" />
</button>
···
onClick={() => handleSave('website')}
disabled={saving}
className="p-1 text-accent hover:text-accent-hover disabled:opacity-50"
+
title={isProposal ? "Submit proposal" : "Save changes"}
>
<Save className="h-3 w-3" />
</button>
···
<ExternalLink className="h-4 w-4 ml-1" />
</a>
) : (
-
<div className={`inline-flex items-center text-muted text-sm ${isOwnProject ? 'cursor-pointer' : 'opacity-50'}`}>
+
<div className={`inline-flex items-center text-muted text-sm ${canEdit ? 'cursor-pointer' : 'opacity-50'}`}>
<ExternalLink className="h-4 w-4 mr-1" />
-
{isOwnProject ? 'Click to add website' : 'Website not specified'}
+
{canEdit ? (isProposal ? 'Click to propose website' : 'Click to add website') : 'Website not specified'}
</div>
)}
-
{isOwnProject && (
+
{canEdit && (
<button
onClick={() => handleEdit('website')}
className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity"
+
title={isProposal ? "Propose change" : "Edit"}
>
<Edit2 className="h-3 w-3" />
</button>
···
onClick={() => handleSave('categories')}
disabled={saving}
className="p-1 text-accent hover:text-accent-hover disabled:opacity-50"
+
title={isProposal ? "Submit proposal" : "Save changes"}
>
<Save className="h-3 w-3" />
</button>
···
))
) : (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-surface-hover text-muted border-dashed border border-border">
-
{isOwnProject ? '+ Add categories' : 'No categories set'}
+
{canEdit ? (isProposal ? '+ Propose categories' : '+ Add categories') : 'No categories set'}
</span>
)}
-
{isOwnProject && (
+
{canEdit && (
<button
onClick={() => handleEdit('categories')}
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-surface-hover text-muted hover:text-primary border-dashed border border-border opacity-0 group-hover:opacity-100 transition-opacity ml-1"
+
title={isProposal ? "Propose categories" : "Edit categories"}
>
<Edit2 className="h-3 w-3 mr-1" />
-
Edit
+
{isProposal ? 'Propose' : 'Edit'}
</button>
)}
</div>