Context for personal calendar events — CRUD with scope-based authorization built into every function.
Authorization model
Every function takes the caller's PhoenixKit.Users.Auth.Scope and
authorizes against the TARGET calendar's owner:
| Caller vs target | Needs |
|---|---|
| Own calendar | calendar |
| Someone else's, reading | calendar.view_others (or edit_others, which implies view) |
| Someone else's, writing | calendar.edit_others |
All checks go through Scope.can?/2, so they also require the calendar
module to be enabled — a stale scope can't keep operating after the
module is switched off.
Two invariants hold regardless of the calling UI:
owner_uuidis never taken from user-supplied attrs. Creation takes it as an explicit, separately-authorized argument; the schema doesn't cast it; updates can't move an event to another calendar.- Mutations on existing events are load-then-authorize: the event's PERSISTED owner decides the required permission, not anything the caller claims.
Summary
Functions
Whether the scope may WRITE (create/update/delete events) on the
calendar owned by owner_uuid. Own calendar needs calendar;
others' need calendar.edit_others.
Whether the scope may READ the calendar owned by owner_uuid.
Own calendar needs calendar; others' need calendar.view_others
(or calendar.edit_others, which implies viewing).
Map of owner_uuid => count of events they OWN across all calendars.
Requires cross-calendar read access (calendar.view_others /
calendar.edit_others).
Creates an event on owner_uuid's calendar.
Deletes an event. Same load-then-authorize rule as update_event/4.
Fetches one event, authorizing READ access against its persisted owner.
Lists EVERYONE's events overlapping the window — the combined "who is
busy when" view. Requires cross-calendar read access
(calendar.view_others / calendar.edit_others); a caller with only
the base key gets {:error, :unauthorized}.
Lists the events on owner_uuid's calendar overlapping the given
window (both bounds are dates; the window is [from, until)).
Whether the scope's user currently resolves as a participant of the
event (live — see participant_visible_dynamic/1). A pure predicate:
callers making ACCESS decisions must combine it with module enablement,
as get_event/2 does.
The PubSub topic carrying {:calendar_event_changed, owner_uuid} after every
committed create/update/delete. A LiveView showing calendars subscribes and
reloads when a change lands for an owner in its current view.
Whether the scope may READ this event: owner-level view access, OR being a live-resolved participant of THIS event (which grants visibility of this event only, never the rest of the owner's calendar). Both require the calendar module to be enabled — module off seals every path.
Updates an event. Authorization runs against the event's PERSISTED
owner (load-then-authorize); the changeset cannot move the event to a
different calendar because owner_uuid is not castable.
Functions
@spec can_edit?(PhoenixKit.Users.Auth.Scope.t() | nil, String.t()) :: boolean()
Whether the scope may WRITE (create/update/delete events) on the
calendar owned by owner_uuid. Own calendar needs calendar;
others' need calendar.edit_others.
@spec can_view?(PhoenixKit.Users.Auth.Scope.t() | nil, String.t()) :: boolean()
Whether the scope may READ the calendar owned by owner_uuid.
Own calendar needs calendar; others' need calendar.view_others
(or calendar.edit_others, which implies viewing).
@spec count_events_by_owner( PhoenixKit.Users.Auth.Scope.t() | nil, Date.t() | nil, Date.t() | nil, keyword() ) :: {:ok, %{required(String.t()) => non_neg_integer()}} | {:error, :unauthorized}
Map of owner_uuid => count of events they OWN across all calendars.
Requires cross-calendar read access (calendar.view_others /
calendar.edit_others).
Pass a from/until date window to count only events overlapping it —
that powers the person panel's "empty" badge, which flags a person whose
own calendar has no events in the visible range. The count is
ownership-scoped by design: the badge answers "is this CALENDAR empty
here" (the panel picks calendars to view), not "would soloing them show
nothing" — soloing additionally overlays events on OTHER calendars they
merely participate in, which belong to those owners' counts.
@spec create_event( PhoenixKit.Users.Auth.Scope.t() | nil, String.t(), map(), keyword() ) :: {:ok, PhoenixKitCalendar.Schemas.Event.t()} | {:error, :unauthorized | Ecto.Changeset.t()}
Creates an event on owner_uuid's calendar.
owner_uuid is an explicit argument — never read from attrs — and is
authorized before the changeset ever runs. opts may carry :actor_uuid
for activity logging (defaults to the scope's user).
@spec delete_event( PhoenixKit.Users.Auth.Scope.t() | nil, PhoenixKitCalendar.Schemas.Event.t(), keyword() ) :: {:ok, PhoenixKitCalendar.Schemas.Event.t()} | {:error, :unauthorized | :not_found | Ecto.Changeset.t()}
Deletes an event. Same load-then-authorize rule as update_event/4.
@spec get_event(PhoenixKit.Users.Auth.Scope.t() | nil, String.t()) :: {:ok, PhoenixKitCalendar.Schemas.Event.t()} | {:error, :not_found | :unauthorized}
Fetches one event, authorizing READ access against its persisted owner.
@spec list_all_events( PhoenixKit.Users.Auth.Scope.t() | nil, Date.t(), Date.t(), keyword() ) :: {:ok, [PhoenixKitCalendar.Schemas.Event.t()]} | {:error, :unauthorized}
Lists EVERYONE's events overlapping the window — the combined "who is
busy when" view. Requires cross-calendar read access
(calendar.view_others / calendar.edit_others); a caller with only
the base key gets {:error, :unauthorized}.
Options
:owner_uuids— restrict to these calendars (the person-filter toggles in the Everyone view). Omit ornilfor all calendars.
@spec list_events( PhoenixKit.Users.Auth.Scope.t() | nil, String.t(), Date.t(), Date.t(), keyword() ) :: {:ok, [PhoenixKitCalendar.Schemas.Event.t()]} | {:error, :unauthorized}
Lists the events on owner_uuid's calendar overlapping the given
window (both bounds are dates; the window is [from, until)).
Returns {:ok, events} or {:error, :unauthorized}.
@spec participant?( PhoenixKit.Users.Auth.Scope.t() | nil, PhoenixKitCalendar.Schemas.Event.t() ) :: boolean()
Whether the scope's user currently resolves as a participant of the
event (live — see participant_visible_dynamic/1). A pure predicate:
callers making ACCESS decisions must combine it with module enablement,
as get_event/2 does.
@spec pubsub_topic() :: String.t()
The PubSub topic carrying {:calendar_event_changed, owner_uuid} after every
committed create/update/delete. A LiveView showing calendars subscribes and
reloads when a change lands for an owner in its current view.
@spec readable?( PhoenixKit.Users.Auth.Scope.t() | nil, PhoenixKitCalendar.Schemas.Event.t() ) :: boolean()
Whether the scope may READ this event: owner-level view access, OR being a live-resolved participant of THIS event (which grants visibility of this event only, never the rest of the owner's calendar). Both require the calendar module to be enabled — module off seals every path.
This is the single authorization predicate the participant read paths share
with get_event/2, so a participant sees the event AND its participant list
under the same rule.
@spec update_event( PhoenixKit.Users.Auth.Scope.t() | nil, PhoenixKitCalendar.Schemas.Event.t(), map(), keyword() ) :: {:ok, PhoenixKitCalendar.Schemas.Event.t()} | {:error, :unauthorized | :not_found | Ecto.Changeset.t()}
Updates an event. Authorization runs against the event's PERSISTED
owner (load-then-authorize); the changeset cannot move the event to a
different calendar because owner_uuid is not castable.