diff --git a/app/api/auth/unclaim/route.ts b/app/api/auth/unclaim/route.ts index 136f282..f7e6e68 100644 --- a/app/api/auth/unclaim/route.ts +++ b/app/api/auth/unclaim/route.ts @@ -27,10 +27,10 @@ export async function POST(request: NextRequest) { ); } - // Safety: don't let admins unclaim other admins - if (targetUser.is_admin) { + // Safety: don't let admins unclaim *other* admins (self-unclaim is fine) + if (targetUser.is_admin && targetUser.id !== user.id) { return NextResponse.json( - { error: 'Cannot unclaim an admin user' }, + { error: 'Cannot unclaim another admin user' }, { status: 403 } ); } diff --git a/app/api/relationship-requests/[id]/route.ts b/app/api/relationship-requests/[id]/route.ts index 8af0091..242b603 100644 --- a/app/api/relationship-requests/[id]/route.ts +++ b/app/api/relationship-requests/[id]/route.ts @@ -40,9 +40,17 @@ export async function PATCH( ); } - // Permission: must be able to edit the target entity (owner or admin) - const canEdit = await canEditEntity(user, req.target_id); - if (!canEdit) { + // Permission: must be able to edit the target or source entity (owner or admin). + // Requests can be initiated by either side's owner, so the other side approves. + const [canEditTarget, canEditSource] = await Promise.all([ + canEditEntity(user, req.target_id), + canEditEntity(user, req.source_id), + ]); + // The requester can't approve their own request — the *other* side must approve + const isRequester = user.id === req.requester_id; + const canDecide = (canEditTarget || canEditSource) && !isRequester; + // Admins can always decide + if (!canDecide && !user.is_admin) { return NextResponse.json( { error: 'You do not have permission to decide this request' }, { status: 403 } diff --git a/app/api/relationships/route.ts b/app/api/relationships/route.ts index 3cc83df..a3c7328 100644 --- a/app/api/relationships/route.ts +++ b/app/api/relationships/route.ts @@ -71,7 +71,7 @@ export async function POST(request: NextRequest) { } } - // Permission check: admin can do anything, non-admin must be able to edit the source entity + // Permission check: admin can do anything if (!user.is_admin) { // Privilege-granting rel types are admin-only if (ADMIN_ONLY_REL_TYPES.includes(rel_type)) { @@ -81,62 +81,72 @@ export async function POST(request: NextRequest) { ); } - // Non-admin must have edit permission on the source entity - const canEdit = await canEditEntity(user, sourceId); - if (!canEdit) { + // Non-admin must have edit permission on at least one side + const [canEditSource, canEditTarget] = await Promise.all([ + canEditEntity(user, sourceId), + canEditEntity(user, targetId), + ]); + + if (!canEditSource && !canEditTarget) { return NextResponse.json( { error: 'You do not have permission to add connections to this entity' }, { status: 403 } ); } - // Requires-approval types: create a pending request instead, - // unless the requester can edit the target (auto-approve shortcut). - if (REQUIRES_APPROVAL_REL_TYPES.includes(rel_type)) { - const canEditTarget = await canEditEntity(user, targetId); - if (!canEditTarget) { - try { - const pendingRequest = await createRelationshipRequest( - user.id, - sourceId, - targetId, - rel_type, - label ?? null - ); - - // Fire-and-forget: notify the target entity owner - getEntityOwner(targetId).then(owner => { - if (!owner) return; - const targetRouteSegment = ENTITY_TYPE_CONFIG[target!.type as EntityType]?.routeSegment ?? target!.type; - const tokenExp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; // 7 days - const approveToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'approve', userId: owner.id, exp: tokenExp }); - const rejectToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'reject', userId: owner.id, exp: tokenExp }); - createNotification(owner.id, 'relationship_request_received', { - requesterName: user.name, - targetName: target!.name, - targetType: target!.type, - targetSlug: target!.slug, - targetRouteSegment, - relLabel: relTypeRow?.forward_label?.toLowerCase() ?? rel_type, - requestId: pendingRequest.id, - approveToken, - rejectToken, - recipientEmail: owner.email, - }).catch(err => console.error('Failed to create request-received notification:', err)); - }).catch(err => console.error('Failed to look up entity owner for notification:', err)); - - return NextResponse.json( - { ...pendingRequest, _type: 'request' }, - { status: 202 } - ); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to create request'; - const isDuplicate = (err as { code?: string })?.code === '23505'; - return NextResponse.json( - { error: isDuplicate ? 'A pending request for this relationship already exists' : message }, - { status: isDuplicate ? 409 : 500 } - ); - } + // Determine if approval is needed: the side the user does NOT control + // must approve. For requires-approval types this is always enforced. + // For other types, approval is needed when the user only controls one side. + const needsApproval = REQUIRES_APPROVAL_REL_TYPES.includes(rel_type) + ? !canEditSource || !canEditTarget // approval unless user controls both sides + : !canEditSource && canEditTarget; // non-approval type: need source owner's OK if user only controls target + + if (needsApproval) { + // Notify the owner of the side the user does NOT control + const approvalEntityId = canEditSource ? targetId : sourceId; + const approvalEntity = canEditSource ? target! : source!; + + try { + const pendingRequest = await createRelationshipRequest( + user.id, + sourceId, + targetId, + rel_type, + label ?? null + ); + + // Fire-and-forget: notify the entity owner who needs to approve + getEntityOwner(approvalEntityId).then(owner => { + if (!owner) return; + const routeSegment = ENTITY_TYPE_CONFIG[approvalEntity.type as EntityType]?.routeSegment ?? approvalEntity.type; + const tokenExp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; // 7 days + const approveToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'approve', userId: owner.id, exp: tokenExp }); + const rejectToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'reject', userId: owner.id, exp: tokenExp }); + createNotification(owner.id, 'relationship_request_received', { + requesterName: user.name, + targetName: approvalEntity.name, + targetType: approvalEntity.type, + targetSlug: approvalEntity.slug, + targetRouteSegment: routeSegment, + relLabel: relTypeRow?.forward_label?.toLowerCase() ?? rel_type, + requestId: pendingRequest.id, + approveToken, + rejectToken, + recipientEmail: owner.email, + }).catch(err => console.error('Failed to create request-received notification:', err)); + }).catch(err => console.error('Failed to look up entity owner for notification:', err)); + + return NextResponse.json( + { ...pendingRequest, _type: 'request' }, + { status: 202 } + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create request'; + const isDuplicate = (err as { code?: string })?.code === '23505'; + return NextResponse.json( + { error: isDuplicate ? 'A pending request for this relationship already exists' : message }, + { status: isDuplicate ? 409 : 500 } + ); } } } diff --git a/app/events/[slug]/page.tsx b/app/events/[slug]/page.tsx index 6d0facc..1041aa1 100644 --- a/app/events/[slug]/page.tsx +++ b/app/events/[slug]/page.tsx @@ -111,7 +111,7 @@ export default async function EventPage({ params }: Props) { )} {/* Date / Time / Duration / Location */} - {(meta.date || meta.time || meta.duration || meta.location) && ( + {(meta.date || meta.time || meta.duration || (meta.location && !venueRel)) && (
{formatDuration(meta.duration)}
+ If someone else has claimed your person profile, organization, project, or any + other entity that belongs to you, email{' '} + + rob42bob@gmail.com + {' '} + and we'll sort it out. +
++ Any other problems? Contact:{' '} + + rob42bob@gmail.com + +
- Added {formatDate(entity.created_at)} -
- {isClaimed && !currentUser && ( -+ Added {formatDate(entity.created_at)} +