01.software 문서

Events

로그인 정책 한계를 명시하고 일정, 무료 RSVP, guest 조회와 취소를 완료합니다.

Events

준비

events를 활성화하고 공개된 public calendar, registrationPolicy: rsvp(또는 approval)인 published public/unlisted event, 접수 중이며 정원이 있는 occurrence를 준비하세요. 무료 direct RSVP intake 예제이며 cart·checkout·유료 입장·운영자 등록 endpoint는 사용하지 않습니다. 보통 rsvp는 going, approval은 pending이며 명시적인 auto-waitlist 정책에서 정원이 차면 waitlisted가 될 수 있습니다. 성공을 모두 “확정”으로 바꾸지 말고 반환된 status를 표시하세요.

신규 로그인 연동은 먼저 Customers의 파일을 모두 복사하고 같은 tenant의 hosted client·origin·redirect와 HttpOnly session을 설정합니다. 공개 일정 조회와 guest intake 자체에는 고객 신원이 필수가 아닙니다. 브라우저 호출의 API origin admission은 별도입니다.

예제는 공개 SDK 0.65.1, Next.js 16.3.8 App Router, React 19.2.4, Node 24.15.0 조합입니다. 자신의 앱에 npm install @01.software/sdk@0.65.1로 설치하세요. force-dynamic으로 SDK 호출을 build가 아닌 요청 때 실행합니다. cacheComponents와 static export를 쓰지 않는 구성입니다. 컴파일과 통제된 로컬 transport 검사는 hosted 제공 여부나 원격 tenant admission의 증거가 아닙니다.

일정과 무료 RSVP 구현

Customers session helper 다음에 아래 파일을 복사하세요. signupRequired는 플랫폼 event 필드가 아닌 앱 소유 상수입니다. true이면 이 앱의 trusted Server Action이 hosted me() readback을 먼저 확인합니다. 앱에서 guest를 허용하려면 false로 바꾸세요. 제시된 session이 유효하지 않으면 익명으로 강등하지 않고 다시 로그인하게 합니다. caller가 지정한 customer·tenant를 SDK에 전달하지 않습니다.

플랫폼에는 현재 서버가 강제하는 signupRequired 정책이 없습니다. Direct public intake는 registration policy·publication/visibility·calendar/occurrence·중복·capacity를 검사하며 customer JWT는 선택입니다. 이 앱의 로그인 요구만으로 다른 클라이언트가 Publishable Key로 guest direct intake를 호출하는 경로를 막을 수 없습니다. 이 레시피는 tenant 전체의 authenticated-only 등록을 보장하지 않습니다.

// app/events/page.tsx
import { createClient } from '@01.software/sdk'
import { registerRsvp } from './actions'

export const dynamic = 'force-dynamic'

export default async function EventsPage() {
  const client = createClient({
    publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
    apiUrl: process.env.SOFTWARE_API_URL,
  })
  const range = await client.events.getRange({ limit: 10 })
  const occurrences = range.docs.filter(
    (row) =>
      row.status === 'scheduled' &&
      ['rsvp', 'approval'].includes(row.event.registrationPolicy ?? ''),
  )
  if (occurrences.length === 0)
    return <p>No open RSVP occurrences in this range.</p>
  return (
    <ul>
      {occurrences.map((occurrence) => (
        <li key={occurrence.id}>
          <h2>{occurrence.event.title ?? 'Untitled event'}</h2>
          <p>
            {occurrence.isAllDay
              ? `${occurrence.allDayStartDate} / ${occurrence.allDayEndDateExclusive} (exclusive)`
              : occurrence.startsAt}
          </p>
          <form action={registerRsvp}>
            <input
              type="hidden"
              name="event"
              value={String(occurrence.event.id)}
            />
            <input
              type="hidden"
              name="occurrence"
              value={String(occurrence.id)}
            />
            <input
              name="email"
              type="email"
              required
              aria-label="Attendee email"
            />
            <button>RSVP</button>
          </form>
        </li>
      ))}
    </ul>
  )
}
// app/events/actions.ts
'use server'

import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { currentCustomer, cookieOptions } from '@/lib/customer'

// App-owned policy. false permits guests; true gates this app's action only.
const signupRequired = true

export async function registerRsvp(form: FormData) {
  const { client, customer, hadToken } = await currentCustomer()
  if ((signupRequired || hadToken) && !customer) redirect('/api/account/login')
  const event = String(form.get('event') ?? '')
  const occurrence = String(form.get('occurrence') ?? '')
  const email = String(form.get('email') ?? '').trim()
  if (!event || !occurrence || !email)
    throw new Error('Select an event and enter an email')
  const result = await client.events.register({
    event,
    occurrence,
    quantity: 1,
    attendee: { email },
  })
  // Token is a capability: never put it in a URL, log, or public response.
  const jar = await cookies()
  jar.set('__Host-rsvp-token', result.guestToken, {
    ...cookieOptions,
    expires: new Date(result.guestTokenExpiresAt),
  })
  redirect('/events/manage')
}
// app/events/manage/page.tsx
import { cookies } from 'next/headers'
import { customerClient, cookieOptions } from '@/lib/customer'
import { CancelRsvp } from './cancel-form'

export const dynamic = 'force-dynamic'

export default async function ManageRsvp() {
  const token = (await cookies()).get('__Host-rsvp-token')?.value
  const registration = token
    ? (await customerClient().events.getGuestRegistration(token)).registration
    : null
  async function cancel(): Promise<'canceled'> {
    'use server'
    const jar = await cookies()
    const current = jar.get('__Host-rsvp-token')?.value
    if (!current) throw new Error('RSVP token missing')
    const result =
      await customerClient().events.cancelGuestRegistration(current)
    if (result.registration.registrationStatus !== 'canceled')
      throw new Error('Cancellation unconfirmed')
    // Successful cancellation revokes the guest token; never look it up again.
    jar.set('__Host-rsvp-token', '', { ...cookieOptions, maxAge: 0 })
    return 'canceled'
  }
  return (
    <main>
      <h1>Manage RSVP</h1>
      <CancelRsvp
        registrationStatus={registration?.registrationStatus ?? null}
        cancel={cancel}
      />
    </main>
  )
}
// app/events/manage/cancel-form.tsx
'use client'

import { useActionState } from 'react'

type CancelState = 'ready' | 'canceled' | 'failed'

export function CancelRsvp({
  registrationStatus,
  cancel,
}: {
  registrationStatus: string | null
  cancel: () => Promise<'canceled'>
}) {
  const [state, submit, pending] = useActionState<CancelState, FormData>(
    async () => {
      try {
        return await cancel()
      } catch {
        return 'failed'
      }
    },
    'ready',
  )
  if (state === 'canceled') return <p role="status">RSVP canceled.</p>
  if (!registrationStatus)
    return (
      <p>
        No RSVP token in this browser. Make a new reservation or contact
        support.
      </p>
    )
  if (!['pending', 'going', 'waitlisted'].includes(registrationStatus))
    return <p>RSVP: {registrationStatus}. Contact support for changes.</p>
  return (
    <form action={submit}>
      <p>RSVP: {registrationStatus}</p>
      {state === 'failed' && (
        <p role="alert">
          Cancellation unconfirmed. Contact support before retrying.
        </p>
      )}
      <button disabled={pending}>Cancel RSVP</button>
    </form>
  )
}

정상·빈 결과·실패

/events는 scheduled이고 registration policy가 rsvp/approval인 occurrence만 제공합니다. 필터 결과가 비면 정상 빈 상태이며 none/external 이벤트나 completed occurrence에는 RSVP 폼이 없습니다. 표시 필터는 서버 admission을 대체하거나 좌석을 예약하지 않습니다. 앱이 getRange({ limit: 10 }) 이후에 필터하므로 첫 페이지의 필터 결과가 비었다고 전체 calendar에 RSVP 일정이 없다고 판단할 수 없습니다. 그 결론을 내리기 전에 페이지를 이어서 조회하세요. declined 등록에는 상태만 표시하고 Cancel 버튼을 숨깁니다. 종일 일정은 canonical start/exclusive-end date를 날짜로 표시하며 viewer timezone 변환으로 복원하지 않습니다. occurrence를 골라 RSVP를 한 번 제출하고 /events/manage에서 별도로 조회하세요. 취소 시 registrationStatus: canceled인 성공 응답을 받은 경우에만 완료 메시지를 표시하고 guest-token 쿠키를 삭제합니다. 취소는 token을 폐기하므로 같은 token으로 이동·재조회하지 마세요. 이후 조회와 재취소는 401이며 reload하면 token이 없는 상태를 표시합니다. 취소 전 등록 상태는 조회가 근거이고, 취소 후 독립 readback은 폐기된 guest token 대신 권한 있는 tenant 운영자가 수행해야 합니다. guest token은 한 번 반환되고 서버에는 hash가 남습니다. 예제는 Secure HttpOnly 쿠키에 RSVP capability 하나만 보관하므로 다음 RSVP가 이전 것을 대체합니다. 분실하면 비공개 등록 목록을 조회하는 대신 운영자 지원 절차를 따릅니다. guest 조회 응답은 참석자 PII를 포함할 수 있으므로 status만 표시하고 응답을 덤프하지 마세요.

쿠키가 없으면 관리 화면의 빈 상태를 표시합니다. 운영자가 등록이나 occurrence를 취소한 뒤에도 쿠키가 남아 token이 거부될 수 있습니다. 관리 페이지의 읽기 오류 UI에는 “RSVP 접근 불가. 이벤트 운영자에게 상태 확인과 이 브라우저의 RSVP 쿠키 초기화를 요청하세요.”를 표시하세요. 같은 거부 조회를 반복해도 접근은 복구되지 않으며 쿠키 삭제만으로 취소를 증명할 수 없습니다. 무효·만료 guest token, 접수 종료, waitlist 없는 정원 초과, 중복 등록, feature 거부, 네트워크 실패는 오류로 남겨야 합니다. 다른 tenant·잘못된 고객 JWT로 me()가 null을 반환하면 이 앱의 action은 로그인으로 redirect하며 guest intake로 조용히 낮추지 않습니다. 페이지에는 읽기 재시도 boundary를, action에는 등록·취소를 자동 재전송하지 않는 실패 UI를 추가하세요. 응답 유실 때도 등록은 commit됐을 수 있으므로 재시도 전에 조사·readback합니다. 401을 포함한 취소 실패는 완료로 표시하거나 취소가 확인된 것처럼 쿠키를 삭제하지 마세요. 응답을 잃었다면 권한 있는 tenant 운영자에게 상태 확인을 요청하세요. Token 거부만으로 취소 성공을 증명할 수 없습니다.

Starter 호환과 로컬 확인

빠른 시작에서 검증한 공개 create 0.2.5 → init 0.14.3에는 Events RSVP 0.1.21, SDK 0.65.0 조합이 포함됩니다. 그 starter는 guest-first·무료이며 선택 customer-account 화면은 이 신규 hosted 레시피가 아닌 direct legacy 인증입니다. 서버 signupRequired 강제는 없습니다. Starter의 guest 링크에는 capability token이 들어갈 수 있으므로 log·referrer 유출에 주의하고 신규 앱은 이 쿠키 패턴을 사용하세요. 무료 RSVP에 checkout·결제 키를 추가할 필요는 없습니다. mock demo와 통제된 로컬 transport는 실제 admission이나 hosted 배포 증거가 아닙니다.

Guest-first 등록 → 조회 → 취소, 이어서 signupRequired: true일 때 이 앱의 로그인·미로그인 경로를 검증하세요. Public intake가 열려 있으면 별도 guest direct 호출도 가능한지 확인합니다. 서버 강제 signupRequired는 미제공이며 PASS가 아닙니다. 원격 test-tenant readback·denial·cleanup과 hosted 전체 여정은 같은 staging SHA에서 실행할 때까지 pending입니다.

다음 액션

Calendar 소유권과 occurrence 취소는 콘텐츠 운영, 유료 admission availability는 SDK, 출시 전에는 배포를 따릅니다. Availability는 좌석 예약이 아닌 힌트이며 유료 티켓은 기존 Ecommerce 흐름을 사용합니다. 이 무료 RSVP 작업에는 포함하지 않습니다.

다음 init 릴리스에서는 별도의 events-rsvp 스타터를 내립니다. 새로운 무료 RSVP·승인제 또는 유료 행사 앱에는 --template events를 사용하세요. 이전 이름은 종료 코드 1로 실패하고 대체 이름을 안내합니다. Events의 기본 요구 기능은 Events + Ecommerce로 유지됩니다. 위 고정 버전의 과거 검증 기록과 이미 생성한 앱은 바뀌지 않습니다. 다음 릴리스 목록과 게시 여부 주의사항은 빌드 옵션을 확인하세요.

목차