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 24 updatedAt?: string 25 25 } 26 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 + 27 45 async function getSessionAgent(): Promise<Agent | null> { 28 46 try { 29 47 const session = await getSession() ··· 127 145 } 128 146 129 147 const body = await request.json() 148 + const { targetDid, isProposal, ...recordData } = body 130 149 131 150 console.log('Debug - POST request body:', body) 132 151 console.log('Debug - Website in request body:', body.website) 152 + console.log('Debug - isProposal:', isProposal, 'targetDid:', targetDid) 133 153 134 - // Check if there's an existing record 135 - let existingRecord = null 136 - let rkey = TID.nextStr() 154 + const now = new Date().toISOString() 137 155 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() 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, 149 175 } 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 176 173 - console.log('Debug - Final record to save:', record) 174 - console.log('Debug - Website in final record:', record.website) 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 + }) 175 183 176 - // Remove undefined fields 177 - Object.keys(record).forEach(key => { 178 - if ((record as any)[key] === undefined) { 179 - delete (record as any)[key] 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, 180 242 } 181 - }) 182 243 183 - // Basic validation 184 - if (!record.createdAt) { 185 - return NextResponse.json({ error: 'Invalid project status data: createdAt required' }, { status: 400 }) 186 - } 244 + console.log('Debug - Final record to save:', record) 245 + console.log('Debug - Website in final record:', record.website) 187 246 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 247 + // Remove undefined fields 248 + Object.keys(record).forEach(key => { 249 + if ((record as any)[key] === undefined) { 250 + delete (record as any)[key] 251 + } 202 252 }) 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 }) 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 + } 206 279 } 207 280 } catch (error) { 208 281 console.error('Project status update failed:', error)
+52 -13
app/project/[id]/page.tsx
··· 29 29 30 30 // Check if this is the user's own project 31 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 32 35 console.log('Debug - isOwnProject:', isOwnProject, 'isDID:', isDID, 'profile?.isOwner:', profile?.isOwner) 36 + console.log('Debug - canEdit:', canEdit, 'isProposal:', isProposal) 33 37 34 38 // Check if we have public/fallback data 35 39 const hasPublicData = statusMessage && statusMessage.includes('public data') ··· 104 108 } 105 109 106 110 const handleSave = async (field: string) => { 107 - if (!profile?.isOwner) { 111 + if (!canEdit) { 108 112 console.error('Not authorized to edit this profile') 109 113 return 110 114 } ··· 128 132 (projectStatus?.categories || []), 129 133 impactMetrics: projectStatus?.impactMetrics || [], 130 134 geographicDistribution: projectStatus?.geographicDistribution || [], 135 + // Add proposal-specific parameters 136 + isProposal, 137 + targetDid: isProposal ? id : undefined, 131 138 } 132 139 133 140 const response = await fetch('/api/project-status', { ··· 139 146 }) 140 147 141 148 if (response.ok) { 142 - // Update local state 143 - setProjectStatus({...projectStatus, ...updatedData}) 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 + } 144 157 // Clear temporary input values 145 158 setEditValues({ 146 159 ...editValues, 147 160 categoriesInput: undefined 148 161 }) 149 162 setEditingField(null) 150 - console.log('Successfully saved', field) 163 + console.log('Successfully saved', field, responseData.isProposal ? 'as proposal' : 'as direct update') 151 164 } else { 152 165 const errorData = await response.json() 153 166 console.error('Save failed:', errorData.error) ··· 269 282 </div> 270 283 )} 271 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 + 272 303 {/* Project Header */} 273 304 <div className="bg-surface border-subtle shadow-card p-4 sm:p-6 group"> 274 305 <div className="flex flex-col sm:flex-row sm:items-start justify-between mb-4 sm:mb-6 gap-4"> ··· 295 326 onClick={() => handleSave('name')} 296 327 disabled={saving} 297 328 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 329 + title={isProposal ? "Submit proposal" : "Save changes"} 298 330 > 299 331 <Save className="h-4 w-4" /> 300 332 </button> ··· 310 342 <h1 className="text-2xl sm:text-3xl font-serif font-semibold text-primary"> 311 343 {editValues.name || project?.name || `${displayName}'s project`} 312 344 </h1> 313 - {isOwnProject && ( 345 + {canEdit && ( 314 346 <button 315 347 onClick={() => handleEdit('name')} 316 348 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 349 + title={isProposal ? "Propose change" : "Edit"} 317 350 > 318 351 <Edit2 className="h-4 w-4" /> 319 352 </button> ··· 338 371 onClick={() => handleSave('description')} 339 372 disabled={saving} 340 373 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 374 + title={isProposal ? "Submit proposal" : "Save changes"} 341 375 > 342 376 <Save className="h-4 w-4" /> 343 377 </button> ··· 352 386 ) : ( 353 387 <div className="flex items-start gap-2"> 354 388 <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.')} 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.')} 356 390 </p> 357 - {isOwnProject && ( 391 + {canEdit && ( 358 392 <button 359 393 onClick={() => handleEdit('description')} 360 394 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 395 + title={isProposal ? "Propose change" : "Edit"} 361 396 > 362 397 <Edit2 className="h-4 w-4" /> 363 398 </button> ··· 382 417 onClick={() => handleSave('website')} 383 418 disabled={saving} 384 419 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 420 + title={isProposal ? "Submit proposal" : "Save changes"} 385 421 > 386 422 <Save className="h-3 w-3" /> 387 423 </button> ··· 405 441 <ExternalLink className="h-4 w-4 ml-1" /> 406 442 </a> 407 443 ) : ( 408 - <div className={`inline-flex items-center text-muted text-sm ${isOwnProject ? 'cursor-pointer' : 'opacity-50'}`}> 444 + <div className={`inline-flex items-center text-muted text-sm ${canEdit ? 'cursor-pointer' : 'opacity-50'}`}> 409 445 <ExternalLink className="h-4 w-4 mr-1" /> 410 - {isOwnProject ? 'Click to add website' : 'Website not specified'} 446 + {canEdit ? (isProposal ? 'Click to propose website' : 'Click to add website') : 'Website not specified'} 411 447 </div> 412 448 )} 413 - {isOwnProject && ( 449 + {canEdit && ( 414 450 <button 415 451 onClick={() => handleEdit('website')} 416 452 className="p-1 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity" 453 + title={isProposal ? "Propose change" : "Edit"} 417 454 > 418 455 <Edit2 className="h-3 w-3" /> 419 456 </button> ··· 512 549 onClick={() => handleSave('categories')} 513 550 disabled={saving} 514 551 className="p-1 text-accent hover:text-accent-hover disabled:opacity-50" 552 + title={isProposal ? "Submit proposal" : "Save changes"} 515 553 > 516 554 <Save className="h-3 w-3" /> 517 555 </button> ··· 536 574 )) 537 575 ) : ( 538 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"> 539 - {isOwnProject ? '+ Add categories' : 'No categories set'} 577 + {canEdit ? (isProposal ? '+ Propose categories' : '+ Add categories') : 'No categories set'} 540 578 </span> 541 579 )} 542 - {isOwnProject && ( 580 + {canEdit && ( 543 581 <button 544 582 onClick={() => handleEdit('categories')} 545 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"} 546 585 > 547 586 <Edit2 className="h-3 w-3 mr-1" /> 548 - Edit 587 + {isProposal ? 'Propose' : 'Edit'} 549 588 </button> 550 589 )} 551 590 </div>