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
··· 24 updatedAt?: string 25 } 26 27 async function getSessionAgent(): Promise<Agent | null> { 28 try { 29 const session = await getSession() ··· 127 } 128 129 const body = await request.json() 130 131 console.log('Debug - POST request body:', body) 132 console.log('Debug - Website in request body:', body.website) 133 134 - // Check if there's an existing record 135 - let existingRecord = null 136 - let rkey = TID.nextStr() 137 138 - try { 139 - const listResponse = await agent.com.atproto.repo.listRecords({ 140 - repo: agent.assertDid, 141 - collection: 'org.impactindexer.status', 142 - limit: 1 143 - }) 144 - 145 - if (listResponse.data.records.length > 0) { 146 - existingRecord = listResponse.data.records[0] 147 - // Extract rkey from URI for updates 148 - rkey = existingRecord.uri.split('/').pop() || TID.nextStr() 149 } 150 - } catch (error) { 151 - console.log('No existing record found, will create new one') 152 - } 153 - 154 - // Create the record 155 - const now = new Date().toISOString() 156 - const record = { 157 - $type: 'org.impactindexer.status', 158 - displayName: body.displayName, 159 - description: body.description, 160 - website: body.website, 161 - fundingReceived: body.fundingReceived, 162 - fundingGivenOut: body.fundingGivenOut, 163 - annualBudget: body.annualBudget, 164 - teamSize: body.teamSize, 165 - sustainableRevenuePercent: body.sustainableRevenuePercent, 166 - categories: body.categories, 167 - impactMetrics: body.impactMetrics ? JSON.stringify(body.impactMetrics) : undefined, 168 - geographicDistribution: body.geographicDistribution ? JSON.stringify(body.geographicDistribution) : undefined, 169 - createdAt: (existingRecord?.value as any)?.createdAt || now, 170 - updatedAt: now, 171 - } 172 173 - console.log('Debug - Final record to save:', record) 174 - console.log('Debug - Website in final record:', record.website) 175 176 - // Remove undefined fields 177 - Object.keys(record).forEach(key => { 178 - if ((record as any)[key] === undefined) { 179 - delete (record as any)[key] 180 } 181 - }) 182 183 - // Basic validation 184 - if (!record.createdAt) { 185 - return NextResponse.json({ error: 'Invalid project status data: createdAt required' }, { status: 400 }) 186 - } 187 188 - // Save the record to ATProto 189 - try { 190 - const response = await agent.com.atproto.repo.putRecord({ 191 - repo: agent.assertDid, 192 - collection: 'org.impactindexer.status', 193 - rkey, 194 - record, 195 - validate: false, 196 - }) 197 - 198 - return NextResponse.json({ 199 - success: true, 200 - uri: response.data.uri, 201 - cid: response.data.cid 202 }) 203 - } catch (error) { 204 - console.error('Failed to write project status record:', error) 205 - return NextResponse.json({ error: 'Failed to save project status' }, { status: 500 }) 206 } 207 } catch (error) { 208 console.error('Project status update failed:', error)
··· 24 updatedAt?: string 25 } 26 27 + interface ImpactIndexerProposal { 28 + $type: 'org.impactindexer.proposal' 29 + targetDid: string 30 + displayName?: string 31 + description?: string 32 + website?: string 33 + fundingReceived?: number 34 + fundingGivenOut?: number 35 + annualBudget?: number 36 + teamSize?: number 37 + sustainableRevenuePercent?: number 38 + categories?: string[] 39 + impactMetrics?: string 40 + geographicDistribution?: string 41 + createdAt: string 42 + updatedAt?: string 43 + } 44 + 45 async function getSessionAgent(): Promise<Agent | null> { 46 try { 47 const session = await getSession() ··· 145 } 146 147 const body = await request.json() 148 + const { targetDid, isProposal, ...recordData } = body 149 150 console.log('Debug - POST request body:', body) 151 console.log('Debug - Website in request body:', body.website) 152 + console.log('Debug - isProposal:', isProposal, 'targetDid:', targetDid) 153 154 + const now = new Date().toISOString() 155 156 + if (isProposal && targetDid) { 157 + // This is a proposal for someone else's project 158 + // Save as a proposal record in the current user's repo 159 + const proposalRecord = { 160 + $type: 'org.impactindexer.proposal', 161 + targetDid, 162 + displayName: recordData.displayName, 163 + description: recordData.description, 164 + website: recordData.website, 165 + fundingReceived: recordData.fundingReceived, 166 + fundingGivenOut: recordData.fundingGivenOut, 167 + annualBudget: recordData.annualBudget, 168 + teamSize: recordData.teamSize, 169 + sustainableRevenuePercent: recordData.sustainableRevenuePercent, 170 + categories: recordData.categories, 171 + impactMetrics: recordData.impactMetrics ? JSON.stringify(recordData.impactMetrics) : undefined, 172 + geographicDistribution: recordData.geographicDistribution ? JSON.stringify(recordData.geographicDistribution) : undefined, 173 + createdAt: now, 174 + updatedAt: now, 175 } 176 177 + // Remove undefined fields 178 + Object.keys(proposalRecord).forEach(key => { 179 + if ((proposalRecord as any)[key] === undefined) { 180 + delete (proposalRecord as any)[key] 181 + } 182 + }) 183 184 + try { 185 + const rkey = TID.nextStr() 186 + const response = await agent.com.atproto.repo.putRecord({ 187 + repo: agent.assertDid, 188 + collection: 'org.impactindexer.proposal', 189 + rkey, 190 + record: proposalRecord, 191 + validate: false, 192 + }) 193 + 194 + return NextResponse.json({ 195 + success: true, 196 + isProposal: true, 197 + uri: response.data.uri, 198 + cid: response.data.cid 199 + }) 200 + } catch (error) { 201 + console.error('Failed to write proposal record:', error) 202 + return NextResponse.json({ error: 'Failed to save proposal' }, { status: 500 }) 203 + } 204 + } else { 205 + // This is a direct update to the user's own project 206 + // Check if there's an existing record 207 + let existingRecord = null 208 + let rkey = TID.nextStr() 209 + 210 + try { 211 + const listResponse = await agent.com.atproto.repo.listRecords({ 212 + repo: agent.assertDid, 213 + collection: 'org.impactindexer.status', 214 + limit: 1 215 + }) 216 + 217 + if (listResponse.data.records.length > 0) { 218 + existingRecord = listResponse.data.records[0] 219 + // Extract rkey from URI for updates 220 + rkey = existingRecord.uri.split('/').pop() || TID.nextStr() 221 + } 222 + } catch (error) { 223 + console.log('No existing record found, will create new one') 224 + } 225 + 226 + // Create the record 227 + const record = { 228 + $type: 'org.impactindexer.status', 229 + displayName: recordData.displayName, 230 + description: recordData.description, 231 + website: recordData.website, 232 + fundingReceived: recordData.fundingReceived, 233 + fundingGivenOut: recordData.fundingGivenOut, 234 + annualBudget: recordData.annualBudget, 235 + teamSize: recordData.teamSize, 236 + sustainableRevenuePercent: recordData.sustainableRevenuePercent, 237 + categories: recordData.categories, 238 + impactMetrics: recordData.impactMetrics ? JSON.stringify(recordData.impactMetrics) : undefined, 239 + geographicDistribution: recordData.geographicDistribution ? JSON.stringify(recordData.geographicDistribution) : undefined, 240 + createdAt: (existingRecord?.value as any)?.createdAt || now, 241 + updatedAt: now, 242 } 243 244 + console.log('Debug - Final record to save:', record) 245 + console.log('Debug - Website in final record:', record.website) 246 247 + // Remove undefined fields 248 + Object.keys(record).forEach(key => { 249 + if ((record as any)[key] === undefined) { 250 + delete (record as any)[key] 251 + } 252 }) 253 + 254 + // Basic validation 255 + if (!record.createdAt) { 256 + return NextResponse.json({ error: 'Invalid project status data: createdAt required' }, { status: 400 }) 257 + } 258 + 259 + // Save the record to ATProto 260 + try { 261 + const response = await agent.com.atproto.repo.putRecord({ 262 + repo: agent.assertDid, 263 + collection: 'org.impactindexer.status', 264 + rkey, 265 + record, 266 + validate: false, 267 + }) 268 + 269 + return NextResponse.json({ 270 + success: true, 271 + isProposal: false, 272 + uri: response.data.uri, 273 + cid: response.data.cid 274 + }) 275 + } catch (error) { 276 + console.error('Failed to write project status record:', error) 277 + return NextResponse.json({ error: 'Failed to save project status' }, { status: 500 }) 278 + } 279 } 280 } catch (error) { 281 console.error('Project status update failed:', error)
+52 -13
app/project/[id]/page.tsx
··· 29 30 // Check if this is the user's own project 31 const isOwnProject = isDID && profile?.isOwner 32 console.log('Debug - isOwnProject:', isOwnProject, 'isDID:', isDID, 'profile?.isOwner:', profile?.isOwner) 33 34 // Check if we have public/fallback data 35 const hasPublicData = statusMessage && statusMessage.includes('public data') ··· 104 } 105 106 const handleSave = async (field: string) => { 107 - if (!profile?.isOwner) { 108 console.error('Not authorized to edit this profile') 109 return 110 } ··· 128 (projectStatus?.categories || []), 129 impactMetrics: projectStatus?.impactMetrics || [], 130 geographicDistribution: projectStatus?.geographicDistribution || [], 131 } 132 133 const response = await fetch('/api/project-status', { ··· 139 }) 140 141 if (response.ok) { 142 - // Update local state 143 - setProjectStatus({...projectStatus, ...updatedData}) 144 // Clear temporary input values 145 setEditValues({ 146 ...editValues, 147 categoriesInput: undefined 148 }) 149 setEditingField(null) 150 - console.log('Successfully saved', field) 151 } else { 152 const errorData = await response.json() 153 console.error('Save failed:', errorData.error) ··· 269 </div> 270 )} 271 272 {/* Project Header */} 273 <div className="bg-surface border-subtle shadow-card p-4 sm:p-6 group"> 274 <div className="flex flex-col sm:flex-row sm:items-start justify-between mb-4 sm:mb-6 gap-4"> ··· 295 onClick={() => handleSave('name')} 296 disabled={saving} 297 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 298 > 299 <Save className="h-4 w-4" /> 300 </button> ··· 310 <h1 className="text-2xl sm:text-3xl font-serif font-semibold text-primary"> 311 {editValues.name || project?.name || `${displayName}'s project`} 312 </h1> 313 - {isOwnProject && ( 314 <button 315 onClick={() => handleEdit('name')} 316 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 317 > 318 <Edit2 className="h-4 w-4" /> 319 </button> ··· 338 onClick={() => handleSave('description')} 339 disabled={saving} 340 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 341 > 342 <Save className="h-4 w-4" /> 343 </button> ··· 352 ) : ( 353 <div className="flex items-start gap-2"> 354 <p className="text-sm sm:text-lg text-secondary leading-relaxed flex-1"> 355 - {editValues.description || project?.description || (isOwnProject ? 'Click to add a description of your environmental project...' : 'No project details have been added yet.')} 356 </p> 357 - {isOwnProject && ( 358 <button 359 onClick={() => handleEdit('description')} 360 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 361 > 362 <Edit2 className="h-4 w-4" /> 363 </button> ··· 382 onClick={() => handleSave('website')} 383 disabled={saving} 384 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 385 > 386 <Save className="h-3 w-3" /> 387 </button> ··· 405 <ExternalLink className="h-4 w-4 ml-1" /> 406 </a> 407 ) : ( 408 - <div className={`inline-flex items-center text-muted text-sm ${isOwnProject ? 'cursor-pointer' : 'opacity-50'}`}> 409 <ExternalLink className="h-4 w-4 mr-1" /> 410 - {isOwnProject ? 'Click to add website' : 'Website not specified'} 411 </div> 412 )} 413 - {isOwnProject && ( 414 <button 415 onClick={() => handleEdit('website')} 416 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 417 > 418 <Edit2 className="h-3 w-3" /> 419 </button> ··· 512 onClick={() => handleSave('categories')} 513 disabled={saving} 514 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 515 > 516 <Save className="h-3 w-3" /> 517 </button> ··· 536 )) 537 ) : ( 538 <span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-surface-hover text-muted border-dashed border border-border"> 539 - {isOwnProject ? '+ Add categories' : 'No categories set'} 540 </span> 541 )} 542 - {isOwnProject && ( 543 <button 544 onClick={() => handleEdit('categories')} 545 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" 546 > 547 <Edit2 className="h-3 w-3 mr-1" /> 548 - Edit 549 </button> 550 )} 551 </div>
··· 29 30 // Check if this is the user's own project 31 const isOwnProject = isDID && profile?.isOwner 32 + // Check if user is authenticated and can make proposals 33 + const canEdit = isDID && profile !== null 34 + const isProposal = isDID && !profile?.isOwner && profile !== null 35 console.log('Debug - isOwnProject:', isOwnProject, 'isDID:', isDID, 'profile?.isOwner:', profile?.isOwner) 36 + console.log('Debug - canEdit:', canEdit, 'isProposal:', isProposal) 37 38 // Check if we have public/fallback data 39 const hasPublicData = statusMessage && statusMessage.includes('public data') ··· 108 } 109 110 const handleSave = async (field: string) => { 111 + if (!canEdit) { 112 console.error('Not authorized to edit this profile') 113 return 114 } ··· 132 (projectStatus?.categories || []), 133 impactMetrics: projectStatus?.impactMetrics || [], 134 geographicDistribution: projectStatus?.geographicDistribution || [], 135 + // Add proposal-specific parameters 136 + isProposal, 137 + targetDid: isProposal ? id : undefined, 138 } 139 140 const response = await fetch('/api/project-status', { ··· 146 }) 147 148 if (response.ok) { 149 + const responseData = await response.json() 150 + if (responseData.isProposal) { 151 + // Handle proposal success 152 + alert('Proposal submitted successfully! The project owner will be able to review your suggested changes.') 153 + } else { 154 + // Handle direct update success 155 + setProjectStatus({...projectStatus, ...updatedData}) 156 + } 157 // Clear temporary input values 158 setEditValues({ 159 ...editValues, 160 categoriesInput: undefined 161 }) 162 setEditingField(null) 163 + console.log('Successfully saved', field, responseData.isProposal ? 'as proposal' : 'as direct update') 164 } else { 165 const errorData = await response.json() 166 console.error('Save failed:', errorData.error) ··· 282 </div> 283 )} 284 285 + {/* Proposal Status Message */} 286 + {isProposal && ( 287 + <div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6"> 288 + <div className="flex items-start"> 289 + <div className="flex-shrink-0"> 290 + <svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor"> 291 + <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" /> 292 + </svg> 293 + </div> 294 + <div className="ml-3"> 295 + <p className="text-sm text-amber-700 dark:text-amber-300"> 296 + 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. 297 + </p> 298 + </div> 299 + </div> 300 + </div> 301 + )} 302 + 303 {/* Project Header */} 304 <div className="bg-surface border-subtle shadow-card p-4 sm:p-6 group"> 305 <div className="flex flex-col sm:flex-row sm:items-start justify-between mb-4 sm:mb-6 gap-4"> ··· 326 onClick={() => handleSave('name')} 327 disabled={saving} 328 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 329 + title={isProposal ? "Submit proposal" : "Save changes"} 330 > 331 <Save className="h-4 w-4" /> 332 </button> ··· 342 <h1 className="text-2xl sm:text-3xl font-serif font-semibold text-primary"> 343 {editValues.name || project?.name || `${displayName}'s project`} 344 </h1> 345 + {canEdit && ( 346 <button 347 onClick={() => handleEdit('name')} 348 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 349 + title={isProposal ? "Propose change" : "Edit"} 350 > 351 <Edit2 className="h-4 w-4" /> 352 </button> ··· 371 onClick={() => handleSave('description')} 372 disabled={saving} 373 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 374 + title={isProposal ? "Submit proposal" : "Save changes"} 375 > 376 <Save className="h-4 w-4" /> 377 </button> ··· 386 ) : ( 387 <div className="flex items-start gap-2"> 388 <p className="text-sm sm:text-lg text-secondary leading-relaxed flex-1"> 389 + {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.')} 390 </p> 391 + {canEdit && ( 392 <button 393 onClick={() => handleEdit('description')} 394 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 395 + title={isProposal ? "Propose change" : "Edit"} 396 > 397 <Edit2 className="h-4 w-4" /> 398 </button> ··· 417 onClick={() => handleSave('website')} 418 disabled={saving} 419 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 420 + title={isProposal ? "Submit proposal" : "Save changes"} 421 > 422 <Save className="h-3 w-3" /> 423 </button> ··· 441 <ExternalLink className="h-4 w-4 ml-1" /> 442 </a> 443 ) : ( 444 + <div className={`inline-flex items-center text-muted text-sm ${canEdit ? 'cursor-pointer' : 'opacity-50'}`}> 445 <ExternalLink className="h-4 w-4 mr-1" /> 446 + {canEdit ? (isProposal ? 'Click to propose website' : 'Click to add website') : 'Website not specified'} 447 </div> 448 )} 449 + {canEdit && ( 450 <button 451 onClick={() => handleEdit('website')} 452 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 453 + title={isProposal ? "Propose change" : "Edit"} 454 > 455 <Edit2 className="h-3 w-3" /> 456 </button> ··· 549 onClick={() => handleSave('categories')} 550 disabled={saving} 551 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 552 + title={isProposal ? "Submit proposal" : "Save changes"} 553 > 554 <Save className="h-3 w-3" /> 555 </button> ··· 574 )) 575 ) : ( 576 <span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-surface-hover text-muted border-dashed border border-border"> 577 + {canEdit ? (isProposal ? '+ Propose categories' : '+ Add categories') : 'No categories set'} 578 </span> 579 )} 580 + {canEdit && ( 581 <button 582 onClick={() => handleEdit('categories')} 583 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" 584 + title={isProposal ? "Propose categories" : "Edit categories"} 585 > 586 <Edit2 className="h-3 w-3 mr-1" /> 587 + {isProposal ? 'Propose' : 'Edit'} 588 </button> 589 )} 590 </div>