Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/api/auth/unclaim/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
}
Expand Down
14 changes: 11 additions & 3 deletions app/api/relationship-requests/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
112 changes: 61 additions & 51 deletions app/api/relationships/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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 }
);
}
}
}
Expand Down
20 changes: 2 additions & 18 deletions app/events/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) && (
<div className="bg-white/5 border border-white/10 rounded-lg p-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{meta.date && (
<div>
Expand Down Expand Up @@ -144,7 +144,7 @@ export default async function EventPage({ params }: Props) {
<p className="text-white">{formatDuration(meta.duration)}</p>
</div>
)}
{meta.location && (
{meta.location && !venueRel && (
<div className="sm:col-span-2">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-1">
Location
Expand All @@ -162,22 +162,6 @@ export default async function EventPage({ params }: Props) {
</div>
)}

{/* Venue link */}
{venueRel && (
<div>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
Venue
</h2>
<Link
href={entityPath(venueRel.related_entity.type, venueRel.related_entity.slug)}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 hover:text-white hover:border-white/20 transition-colors"
>
<span>{ENTITY_TYPE_CONFIG.venue.icon}</span>
{venueRel.related_entity.name}
</Link>
</div>
)}

{/* Links */}
<div>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
Expand Down
25 changes: 21 additions & 4 deletions app/getting-started/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ const STEPS = [
<Link href="/venues" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Venues
</Link>
,{' '}
<Link href="/catalysts" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Catalysts
</Link>
, and{' '}
<Link href="/programs" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Programs
Expand Down Expand Up @@ -111,6 +107,27 @@ export default function GettingStartedPage() {
<span className="text-lg">&rarr;</span>
</Link>
</div>

<div className="bg-white/5 border border-white/10 rounded-xl p-6 space-y-2">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider">
Someone claimed your profile or entity?
</h2>
<p className="text-gray-400 text-sm leading-relaxed">
If someone else has claimed your person profile, organization, project, or any
other entity that belongs to you, email{' '}
<a href="mailto:rob42bob@gmail.com" className="text-blue-400 hover:text-blue-300 transition-colors underline">
rob42bob@gmail.com
</a>{' '}
and we&apos;ll sort it out.
</p>
</div>

<p className="text-center text-sm text-gray-500">
Any other problems? Contact:{' '}
<a href="mailto:rob42bob@gmail.com" className="text-blue-400 hover:text-blue-300 transition-colors underline">
rob42bob@gmail.com
</a>
</p>
</div>
);
}
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default async function HomePage() {
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 6);

const statTypes: EntityType[] = ['person', 'organization', 'event', 'venue', 'project', 'catalyst', 'program'];
const statTypes: EntityType[] = ['person', 'organization', 'event', 'venue', 'project', 'program'];

return (
<div className="space-y-12">
Expand Down
13 changes: 4 additions & 9 deletions app/people/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getEntityPermissionLevel } from '@/lib/permissions';
import InlineAdminControls from '@/components/InlineAdminControls';
import ClaimPersonButton from '@/components/ClaimPersonButton';
import LinkPersonButton from '@/components/LinkPersonButton';
import DisputeClaimButton from '@/components/DisputeClaimButton';


interface Props {
params: Promise<{ slug: string }>;
Expand Down Expand Up @@ -162,14 +162,9 @@ export default async function PersonPage({ params }: Props) {
)}

{/* Metadata footer */}
<div className="flex items-center justify-between">
<p className="text-xs text-gray-600">
Added {formatDate(entity.created_at)}
</p>
{isClaimed && !currentUser && (
<DisputeClaimButton personId={entity.id} personName={entity.name} />
)}
</div>
<p className="text-xs text-gray-600">
Added {formatDate(entity.created_at)}
</p>
</div>

{/* Sidebar */}
Expand Down
Loading