Add per-invitee can_modify toggle for event edit access
Allows event owners to grant individual invitees edit permission via a toggle in the invitee list. Invited editors can modify event details (title, description, time, location) but cannot change calendars, manage invitees, delete events, or bulk-edit recurring series (scope restricted to "this" only). The can_modify flag resets on decline to prevent silent re-grant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b39c961b6
commit
f35798c757
@ -0,0 +1,26 @@
|
||||
"""add can_modify to event_invitations
|
||||
|
||||
Revision ID: 056
|
||||
Revises: 055
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "056"
|
||||
down_revision = "055"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"event_invitations",
|
||||
sa.Column("can_modify", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("event_invitations", "can_modify")
|
||||
@ -1,6 +1,6 @@
|
||||
from sqlalchemy import (
|
||||
CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, func,
|
||||
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, false as sa_false, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
@ -38,6 +38,9 @@ class EventInvitation(Base):
|
||||
display_calendar_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
can_modify: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default=sa_false()
|
||||
)
|
||||
|
||||
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
|
||||
@ -14,10 +14,12 @@ from app.models.calendar_event import CalendarEvent
|
||||
from app.models.event_invitation import EventInvitation
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.schemas.event_invitation import (
|
||||
EventInvitationCreate,
|
||||
EventInvitationRespond,
|
||||
EventInvitationOverrideCreate,
|
||||
UpdateCanModify,
|
||||
UpdateDisplayCalendar,
|
||||
)
|
||||
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission
|
||||
@ -231,6 +233,40 @@ async def update_display_calendar(
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{invitation_id}/can-modify")
|
||||
async def update_can_modify(
|
||||
body: UpdateCanModify,
|
||||
invitation_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Toggle can_modify on an invitation. Owner-only."""
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation)
|
||||
.options(selectinload(EventInvitation.event))
|
||||
.where(EventInvitation.id == invitation_id)
|
||||
)
|
||||
invitation = inv_result.scalar_one_or_none()
|
||||
if not invitation:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
# Only the calendar owner can toggle can_modify (W-03)
|
||||
perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id)
|
||||
if perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access")
|
||||
|
||||
invitation.can_modify = body.can_modify
|
||||
|
||||
response_data = {
|
||||
"id": invitation.id,
|
||||
"event_id": invitation.event_id,
|
||||
"can_modify": invitation.can_modify,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response_data
|
||||
|
||||
|
||||
@router.delete("/{invitation_id}", status_code=204)
|
||||
async def leave_or_revoke_invitation(
|
||||
invitation_id: int = Path(ge=1, le=2147483647),
|
||||
|
||||
@ -34,6 +34,7 @@ def _event_to_dict(
|
||||
display_calendar_id: int | None = None,
|
||||
display_calendar_name: str | None = None,
|
||||
display_calendar_color: str | None = None,
|
||||
can_modify: bool = False,
|
||||
) -> dict:
|
||||
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
||||
# For invited events: use display calendar if set, otherwise fallback to "Invited"/gray
|
||||
@ -72,6 +73,7 @@ def _event_to_dict(
|
||||
"invitation_status": invitation_status,
|
||||
"invitation_id": invitation_id,
|
||||
"display_calendar_id": display_calendar_id,
|
||||
"can_modify": can_modify,
|
||||
}
|
||||
return d
|
||||
|
||||
@ -207,7 +209,7 @@ async def get_events(
|
||||
|
||||
# Build invitation lookup for the current user
|
||||
invited_event_id_set = set(invited_event_ids)
|
||||
invitation_map: dict[int, tuple[str, int, int | None]] = {} # event_id -> (status, invitation_id, display_calendar_id)
|
||||
invitation_map: dict[int, tuple[str, int, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify)
|
||||
if invited_event_ids:
|
||||
inv_result = await db.execute(
|
||||
select(
|
||||
@ -215,13 +217,14 @@ async def get_events(
|
||||
EventInvitation.status,
|
||||
EventInvitation.id,
|
||||
EventInvitation.display_calendar_id,
|
||||
EventInvitation.can_modify,
|
||||
).where(
|
||||
EventInvitation.user_id == current_user.id,
|
||||
EventInvitation.event_id.in_(invited_event_ids),
|
||||
)
|
||||
)
|
||||
for eid, status, inv_id, disp_cal_id in inv_result.all():
|
||||
invitation_map[eid] = (status, inv_id, disp_cal_id)
|
||||
for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
|
||||
invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
|
||||
|
||||
# Batch-fetch display calendars for invited events
|
||||
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None}
|
||||
@ -250,8 +253,9 @@ async def get_events(
|
||||
disp_cal_id = None
|
||||
disp_cal_name = None
|
||||
disp_cal_color = None
|
||||
inv_can_modify = False
|
||||
if is_invited and parent_id in invitation_map:
|
||||
inv_status, inv_id, disp_cal_id = invitation_map[parent_id]
|
||||
inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id]
|
||||
# Check for per-occurrence override
|
||||
if e.id in override_map:
|
||||
inv_status = override_map[e.id]
|
||||
@ -267,6 +271,7 @@ async def get_events(
|
||||
display_calendar_id=disp_cal_id,
|
||||
display_calendar_name=disp_cal_name,
|
||||
display_calendar_color=disp_cal_color,
|
||||
can_modify=inv_can_modify,
|
||||
))
|
||||
|
||||
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
|
||||
@ -409,9 +414,10 @@ async def update_event(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
|
||||
# Event invitees can VIEW events but must NOT be able to edit them.
|
||||
# Do not add invited_event_ids to this query.
|
||||
# Event invitees can VIEW events but must NOT be able to edit them
|
||||
# UNLESS they have can_modify=True (checked in fallback path below).
|
||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
is_invited_editor = False
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
@ -424,14 +430,60 @@ async def update_event(
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if not event:
|
||||
# Fallback: check if user has can_modify invitation for this event
|
||||
# Must check both event_id (direct) and parent_event_id (recurring child)
|
||||
# because invitations are stored against the parent event
|
||||
target_event_result = await db.execute(
|
||||
select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
target_row = target_event_result.one_or_none()
|
||||
if not target_row:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
candidate_ids = [event_id]
|
||||
if target_row[0] is not None:
|
||||
candidate_ids.append(target_row[0])
|
||||
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation).where(
|
||||
EventInvitation.event_id.in_(candidate_ids),
|
||||
EventInvitation.user_id == current_user.id,
|
||||
EventInvitation.can_modify == True,
|
||||
EventInvitation.status.in_(["accepted", "tentative"]),
|
||||
)
|
||||
)
|
||||
inv = inv_result.scalar_one_or_none()
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
# Shared calendar: require create_modify+ and check lock
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
# Load the event directly (bypassing calendar filter)
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
is_invited_editor = True
|
||||
|
||||
update_data = event_update.model_dump(exclude_unset=True)
|
||||
|
||||
if is_invited_editor:
|
||||
# Invited editor restrictions — enforce BEFORE any data mutation
|
||||
scope_peek = update_data.get("edit_scope")
|
||||
# Block all bulk-scope edits on recurring events (C-01/F-01)
|
||||
if event.is_recurring and scope_peek != "this":
|
||||
raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences")
|
||||
# Block calendar moves (C-02)
|
||||
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
|
||||
raise HTTPException(status_code=403, detail="Invited editors cannot move events between calendars")
|
||||
else:
|
||||
# Standard calendar-access path: require create_modify+ permission
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
|
||||
# Lock check applies to both paths (uses owner's calendar_id)
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
|
||||
# Extract scope before applying fields to the model
|
||||
scope: Optional[str] = update_data.pop("edit_scope", None)
|
||||
|
||||
@ -440,6 +492,7 @@ async def update_event(
|
||||
if rule_obj is not None:
|
||||
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
|
||||
|
||||
if not is_invited_editor:
|
||||
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
|
||||
# Only verify ownership when the calendar is actually changing — members submitting
|
||||
# an unchanged calendar_id must not be rejected just because they aren't the owner.
|
||||
|
||||
@ -24,6 +24,11 @@ class UpdateDisplayCalendar(BaseModel):
|
||||
calendar_id: Annotated[int, Field(ge=1, le=2147483647)]
|
||||
|
||||
|
||||
class UpdateCanModify(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
can_modify: bool
|
||||
|
||||
|
||||
class EventInvitationResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@ -36,3 +41,4 @@ class EventInvitationResponse(BaseModel):
|
||||
invitee_name: Optional[str] = None
|
||||
invitee_umbral_name: Optional[str] = None
|
||||
display_calendar_id: Optional[int] = None
|
||||
can_modify: bool = False
|
||||
|
||||
@ -152,6 +152,10 @@ async def respond_to_invitation(
|
||||
invitation.status = status
|
||||
invitation.responded_at = datetime.now()
|
||||
|
||||
# Clear can_modify on decline (F-02: prevent silent re-grant)
|
||||
if status == "declined":
|
||||
invitation.can_modify = False
|
||||
|
||||
# Auto-assign display calendar on accept/tentative (atomic: only if not already set)
|
||||
if status in ("accepted", "tentative"):
|
||||
default_cal = await db.execute(
|
||||
@ -307,6 +311,7 @@ async def get_event_invitations(
|
||||
"responded_at": inv.responded_at,
|
||||
"invitee_name": preferred_name or umbral_name or "Unknown",
|
||||
"invitee_umbral_name": umbral_name or "Unknown",
|
||||
"can_modify": inv.can_modify,
|
||||
}
|
||||
for inv, preferred_name, umbral_name in rows
|
||||
]
|
||||
|
||||
@ -381,7 +381,7 @@ export default function CalendarPage() {
|
||||
end: event.end_datetime || undefined,
|
||||
allDay: event.all_day,
|
||||
color: 'transparent',
|
||||
editable: !event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only',
|
||||
editable: (event.is_invited && !!event.can_modify) || (!event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only'),
|
||||
extendedProps: {
|
||||
is_virtual: event.is_virtual,
|
||||
is_recurring: event.is_recurring,
|
||||
@ -389,6 +389,7 @@ export default function CalendarPage() {
|
||||
calendar_id: event.calendar_id,
|
||||
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||
is_invited: event.is_invited,
|
||||
can_modify: event.can_modify,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@ -262,11 +262,13 @@ export default function EventDetailPanel({
|
||||
invitees, invite, isInviting, respond: respondInvitation,
|
||||
isResponding, override: overrideInvitation, updateDisplayCalendar,
|
||||
isUpdatingDisplayCalendar, leave: leaveInvitation, isLeaving,
|
||||
toggleCanModify, togglingInvitationId,
|
||||
} = useEventInvitations(parentEventId);
|
||||
const { connections } = useConnectedUsersSearch();
|
||||
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
|
||||
|
||||
const isInvitedEvent = !!event?.is_invited;
|
||||
const canModifyAsInvitee = isInvitedEvent && !!event?.can_modify;
|
||||
const myInvitationStatus = event?.invitation_status ?? null;
|
||||
const myInvitationId = event?.invitation_id ?? null;
|
||||
|
||||
@ -313,7 +315,7 @@ export default function EventDetailPanel({
|
||||
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
|
||||
|
||||
// Permission helpers
|
||||
const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
|
||||
const canEdit = canModifyAsInvitee || !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
|
||||
const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
|
||||
|
||||
// Reset state when event changes
|
||||
@ -364,10 +366,13 @@ export default function EventDetailPanel({
|
||||
end_datetime: endDt,
|
||||
all_day: data.all_day,
|
||||
location_id: data.location_id ? parseInt(data.location_id) : null,
|
||||
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
|
||||
is_starred: data.is_starred,
|
||||
recurrence_rule: rule,
|
||||
};
|
||||
// Invited editors cannot change calendars — omit calendar_id from payload
|
||||
if (!canModifyAsInvitee) {
|
||||
payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null;
|
||||
}
|
||||
|
||||
if (event && !isCreating) {
|
||||
if (editScope) payload.edit_scope = editScope;
|
||||
@ -437,7 +442,14 @@ export default function EventDetailPanel({
|
||||
}
|
||||
|
||||
if (isRecurring) {
|
||||
// Invited editors can only edit "this" occurrence — skip scope step
|
||||
if (canModifyAsInvitee) {
|
||||
setEditScope('this');
|
||||
if (event) setEditState(buildEditStateFromEvent(event));
|
||||
setIsEditing(true);
|
||||
} else {
|
||||
setScopeStep('edit');
|
||||
}
|
||||
} else {
|
||||
if (event) setEditState(buildEditStateFromEvent(event));
|
||||
setIsEditing(true);
|
||||
@ -598,8 +610,8 @@ export default function EventDetailPanel({
|
||||
<>
|
||||
{!event?.is_virtual && (
|
||||
<>
|
||||
{/* Edit button — only for own events or shared with edit permission */}
|
||||
{canEdit && !isInvitedEvent && (
|
||||
{/* Edit button — own events, shared with edit permission, or can_modify invitees */}
|
||||
{canEdit && (!isInvitedEvent || canModifyAsInvitee) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@ -776,7 +788,8 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||
{!canModifyAsInvitee && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-calendar">Calendar</Label>
|
||||
<Select
|
||||
@ -790,6 +803,7 @@ export default function EventDetailPanel({
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-location">Location</Label>
|
||||
<LocationPicker
|
||||
@ -822,7 +836,8 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
|
||||
{!canModifyAsInvitee && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||
<Select
|
||||
@ -838,6 +853,7 @@ export default function EventDetailPanel({
|
||||
<option value="monthly_date">Monthly (date)</option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editState.recurrence_type === 'every_n_days' && (
|
||||
<div className="space-y-1">
|
||||
@ -1077,6 +1093,11 @@ export default function EventDetailPanel({
|
||||
<InviteeList
|
||||
invitees={invitees}
|
||||
isRecurringChild={!!event.parent_event_id}
|
||||
isOwner={myPermission === 'owner'}
|
||||
onToggleCanModify={(invitationId, canModify) =>
|
||||
toggleCanModify({ invitationId, canModify })
|
||||
}
|
||||
togglingInvitationId={togglingInvitationId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Users, UserPlus, Search, X } from 'lucide-react';
|
||||
import { Users, UserPlus, Search, X, Pencil, PencilOff } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { EventInvitation, Connection } from '@/types';
|
||||
@ -37,9 +37,12 @@ function AvatarCircle({ name }: { name: string }) {
|
||||
interface InviteeListProps {
|
||||
invitees: EventInvitation[];
|
||||
isRecurringChild?: boolean;
|
||||
isOwner?: boolean;
|
||||
onToggleCanModify?: (invitationId: number, canModify: boolean) => void;
|
||||
togglingInvitationId?: number | null;
|
||||
}
|
||||
|
||||
export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
|
||||
export function InviteeList({ invitees, isRecurringChild, isOwner, onToggleCanModify, togglingInvitationId }: InviteeListProps) {
|
||||
if (invitees.length === 0) return null;
|
||||
|
||||
const goingCount = invitees.filter((i) => i.status === 'accepted').length;
|
||||
@ -61,6 +64,22 @@ export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
|
||||
<div key={inv.id} className="flex items-center gap-2 py-1">
|
||||
<AvatarCircle name={inv.invitee_name} />
|
||||
<span className="text-sm flex-1 truncate">{inv.invitee_name}</span>
|
||||
{isOwner && onToggleCanModify && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleCanModify(inv.id, !inv.can_modify)}
|
||||
disabled={togglingInvitationId === inv.id}
|
||||
title={inv.can_modify ? 'Remove edit access' : 'Allow editing'}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
inv.can_modify
|
||||
? 'text-accent bg-accent/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={inv.can_modify ? { color: 'hsl(var(--accent-color))', backgroundColor: 'hsl(var(--accent-color) / 0.1)' } : undefined}
|
||||
>
|
||||
{inv.can_modify ? <Pencil className="h-3 w-3" /> : <PencilOff className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
<StatusBadge status={inv.status} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
@ -74,6 +75,24 @@ export function useEventInvitations(eventId: number | null) {
|
||||
},
|
||||
});
|
||||
|
||||
const [togglingInvitationId, setTogglingInvitationId] = useState<number | null>(null);
|
||||
const toggleCanModifyMutation = useMutation({
|
||||
mutationFn: async ({ invitationId, canModify }: { invitationId: number; canModify: boolean }) => {
|
||||
setTogglingInvitationId(invitationId);
|
||||
const { data } = await api.put(`/event-invitations/${invitationId}/can-modify`, { can_modify: canModify });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTogglingInvitationId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setTogglingInvitationId(null);
|
||||
toast.error(getErrorMessage(error, 'Failed to update edit access'));
|
||||
},
|
||||
});
|
||||
|
||||
const leaveMutation = useMutation({
|
||||
mutationFn: async (invitationId: number) => {
|
||||
await api.delete(`/event-invitations/${invitationId}`);
|
||||
@ -102,6 +121,9 @@ export function useEventInvitations(eventId: number | null) {
|
||||
isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending,
|
||||
leave: leaveMutation.mutateAsync,
|
||||
isLeaving: leaveMutation.isPending,
|
||||
toggleCanModify: toggleCanModifyMutation.mutateAsync,
|
||||
isTogglingCanModify: toggleCanModifyMutation.isPending,
|
||||
togglingInvitationId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -116,6 +116,7 @@ export interface CalendarEvent {
|
||||
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
|
||||
invitation_id?: number | null;
|
||||
display_calendar_id?: number | null;
|
||||
can_modify?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -502,6 +503,7 @@ export interface EventInvitation {
|
||||
responded_at: string | null;
|
||||
invitee_name: string;
|
||||
invitee_umbral_name: string;
|
||||
can_modify?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingEventInvitation {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user