# Public and Admin Views Two routes over the same `events` data. The public route at `/events` lists upcoming events without auth. The admin route at `/admin/events` shows the same data plus a create form and per-row delete actions. Both pages open a view on one shared `LiveTable`. Mutations are gated inside the LiveTable's `insert`, `update`, and `delete` handlers: only admins succeed; the public page never tries to mutate, but if it did the LiveTable would reject the call server-side. The recipe demonstrates the auth model where everyone reads through the same live primitive and a small set of users mutates it. The admin gate lives on the LiveTable (defense in depth) and on the admin route (page-render gate). The public route is unauthenticated and unauthorized. ## Migration ```bash elements create migration 'add events' -tables=events ``` `app/migrations/-add-events.migration.sql`: ```sql -- add events -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table events ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), title text not null, description text not null default '', eventDate timestamptz not null ); create index eventsEventDateIdx on events (eventDate asc); create trigger eventsTouchUpdatedAt before update on events for each row execute function touchUpdatedAt(); ``` The `eventsEventDateIdx` backs the `where eventDate >= now()` filter and the `order by eventDate asc` clause. `description` defaults to the empty string so the column stays not-null with no default text noise. ## Services Two pages consume the events LiveTable: the public `/events` route and the admin `/admin/events` route. Because more than one page imports it, the file lives in `app/shared/services/` rather than colocated with either page. If only the admin page needed it, the declarations would sit in that page's `template.ehtml`. `app/shared/services/events.ts`: ```ts import { LiveTable, sql } from "@elements/app"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; export interface Event { id: string; createdAt: Date; updatedAt: Date; title: string; description: string; eventDate: Date; } export let events: LiveTable = new LiveTable({ select: () => sql( `select id, createdAt, updatedAt, title, description, eventDate from events where eventDate >= now() - interval '1 hour' order by eventDate asc`, ), insert: (item) => { isUserAdminOrThrow(); return events.insert(item); }, update: (item) => { isUserAdminOrThrow(); return events.update(item); }, delete: (item) => { isUserAdminOrThrow(); return events.delete(item); }, }); ``` The custom `select` limits the initial rows to events from the last hour and into the future. The one-hour overlap gives "happening now" events a window before they fall off the list. It ignores its `(partition, window)` arguments because neither route partitions or windows the view. Both the public and the admin route open a view on this same declaration; the difference is what each template does with it. Every handler calls `isUserAdminOrThrow()` first, the guard from `elements man recipes admin-roles`. It throws when there is no session and when the user is not an admin, so an anonymous browser or a non-admin calling `events.insert(...)` from a devtools console fails before any SQL runs. The handler then returns the raw auto-SQL on the declaration, `events.insert(item)` and the rest. The declaration carries an explicit `LiveTable` annotation because its initializer refers to it. ## Public route ```bash elements create page events ``` `app/pages/events/index.ts`: ```ts import { Request, Response } from "@elements/app"; import events from "./template"; import { events as eventsTable } from "#app/shared/services/events"; export default function route(req: Request, res: Response) { return new events({ events: eventsTable.view() }); } ``` The public route is unauthenticated. Anyone can hit `/events` and see the upcoming events. `app/pages/events/template.ehtml`: ```ehtml import "./style.css"; import { LiveView } from "@elements/app"; import { Event } from "#app/shared/services/events"; )>

upcoming events

  • +a.eventDate - +b.eventDate)}>

    {e.title}

    {e.description}

  • no upcoming events.
``` The public template reads the view. There are no mutation buttons. The broadcast still patches the list when an admin adds, edits, or removes a row, so the public page updates live. ## Admin route ```bash elements create page admin-events ``` `app/pages/admin-events/index.ts`: ```ts import { Request, Response } from "@elements/app"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; import adminEvents from "./template"; import { events as eventsTable } from "#app/shared/services/events"; export default function route(req: Request, res: Response) { isUserAdminOrThrow(); return new adminEvents({ events: eventsTable.view() }); } ``` The admin route checks admin status before opening the view for the template. The page never renders for a non-admin; the LiveTable's mutator checks are the secondary gate. `app/pages/admin-events/template.ehtml`: ```ehtml import "./style.css"; import { LiveView } from "@elements/app"; import { Event } from "#app/shared/services/events"; interface EventInput { title: string; description: string; eventDate: string; } function emptyForm(): EventInput { return { title: "", description: "", eventDate: "" }; } function onSubmit(events: LiveView, form: { value: EventInput }) { if (form.value.title.trim().length === 0) { return; } if (form.value.eventDate.length === 0) { return; } events.insert( { title: form.value.title.trim(), description: form.value.description.trim(), eventDate: new Date(form.value.eventDate), }, () => form.value = emptyForm(), ); } function onDelete(events: LiveView, event: Event) { if (!confirm(`delete "${event.title}"?`)) { return; } events.delete(event); } , private form: { value: EventInput } = { value: emptyForm() }, )>

events (admin)

onSubmit(events, form)}>