Skip to main content

Event Lifecycle

Planned module

The events module is not yet shipped. The lifecycle below is the final design; implementation will live at src/feats/events/ on the new Hono + Supabase + Drizzle stack.

Events follow a strict lifecycle with state transitions and immutability rules.

State Machine

draft → published → started → finished

cancelled

States

StateIn FeedEditableImmutable
draft❌ No✅ Yes❌ No
published✅ Yes✅ Yes❌ No
started✅ Yes❌ No✅ Yes
finished❌ No❌ No✅ Yes
cancelled❌ No❌ No✅ Yes

Transition Rules

Immutability Rules

When now() >= start_at, the event becomes IMmutable.

Locked Fields

After event starts, these fields cannot be changed:

  • title
  • description
  • time (start_at, end_at)
  • location
  • authors
  • visibility
  • capacity
  • type

Allowed Operations After Start

OperationAllowed
Cancel event✅ Yes
Delete (soft)✅ Yes
Restore✅ Yes
View✅ Yes
Like/Join✅ Yes before end

Implementation

The status lives in the Postgres enum event_status (see Events model). Transitions are enforced in the service layer:

// src/feats/events/events.service.ts (planned)
import { db } from "@infra/db/drizzle.client";
import { eventsTable } from "@infra/db/schema/event.schema";

const LOCKED_AFTER_START = [
"title",
"description",
"type",
"visibility",
"startAt",
"endAt",
"location",
"capacity",
] as const;

export function isEventEditable(event: typeof eventsTable.$inferSelect) {
if (event.status === "cancelled" || event.status === "finished") return false;
return new Date() < event.startAt;
}

export function rejectLockedFields(
event: typeof eventsTable.$inferSelect,
patch: Partial<typeof eventsTable.$inferInsert>,
) {
if (new Date() < event.startAt) return;
for (const field of LOCKED_AFTER_START) {
if (field in patch) {
throw new BadRequestHTTPException(`Field "${field}" is locked after start`);
}
}
}

The started and finished transitions are time-derived and computed on read (status_effective), not written by a cron — there's no background worker. A SQL view or a Drizzle helper materializes the effective status when needed:

CREATE VIEW events_with_effective_status AS
SELECT e.*,
CASE
WHEN e.status IN ('cancelled','draft') THEN e.status::text
WHEN now() >= COALESCE(e.end_at, e.start_at + interval '6 hours')
THEN 'finished'
WHEN now() >= e.start_at THEN 'started'
ELSE e.status::text
END AS effective_status
FROM events e;

API Transitions

MethodEndpointTransitionNotes
POST/events→ draftCreates in draft
POST/events/:id/publishdraft → publishedNow in feed
POST/events/:id/cancelpublished → cancelledAny time
POST/events/:id/cancelstarted → cancelledAny time
DELETE/events/:idany → deletedSoft delete
POST/events/:id/restoredeleted → previousBefore finished

Capacity Management

available_slots = capacity - approved_participants_count
  • Creator doesn't count toward capacity
  • Authors don't count toward capacity
  • Only approved group members count

Visibility

VisibilityWho Can SeeWho Can Like
publicEveryoneEveryone
privateFriends onlyFriends only

Author Permissions

Event authors have special permissions:

PermissionWho
Approve groupsEvent authors only
Reject groupsEvent authors only
Remove authorsCreator only
Cancel eventCreator or any author
Edit eventCreator only (before start)