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
| State | In Feed | Editable | Immutable |
|---|---|---|---|
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:
titledescriptiontime(start_at, end_at)locationauthorsvisibilitycapacitytype
Allowed Operations After Start
| Operation | Allowed |
|---|---|
| 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
| Method | Endpoint | Transition | Notes |
|---|---|---|---|
| POST | /events | → draft | Creates in draft |
| POST | /events/:id/publish | draft → published | Now in feed |
| POST | /events/:id/cancel | published → cancelled | Any time |
| POST | /events/:id/cancel | started → cancelled | Any time |
| DELETE | /events/:id | any → deleted | Soft delete |
| POST | /events/:id/restore | deleted → previous | Before 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
| Visibility | Who Can See | Who Can Like |
|---|---|---|
public | Everyone | Everyone |
private | Friends only | Friends only |
Author Permissions
Event authors have special permissions:
| Permission | Who |
|---|---|
| Approve groups | Event authors only |
| Reject groups | Event authors only |
| Remove authors | Creator only |
| Cancel event | Creator or any author |
| Edit event | Creator only (before start) |