@happyvertical/smrt-events

Infinite-nesting event hierarchy with series, participant tracking, recurrence patterns, and a dedicated event-owned asset join.

v0.29.34Events1 Component

Overview

smrt-events provides infinite-nesting event hierarchy with series, participant tracking, recurrence patterns, and the new EventAsset join table for event-owned assets. Events can model anything from conferences with sessions to sports games with periods and goals.

Tenancy

All event models use @TenantScoped({ mode: 'optional' }) with a nullable tenantId. Global events (tenantId = null) are visible to every tenant — useful for shared reference calendars (public holidays, market hours).

Installation

bash
npm install @happyvertical/smrt-events

Quick Start

typescript
import {
  EventCollection,
  EventSeriesCollection,
  EventParticipantCollection,
} from '@happyvertical/smrt-events';

const events = await EventCollection.create({ db });
const series = await EventSeriesCollection.create({ db });
const participants = await EventParticipantCollection.create({ db });

const playoffs = await series.create({
  name: 'NBA Playoffs 2026',
  typeId: tournamentTypeId,
  organizerId: nbaProfileId,
  startDate: new Date('2026-04-15'),
  endDate: new Date('2026-06-20'),
});
await playoffs.save();

const game = await events.create({
  name: 'Lakers vs Warriors',
  seriesId: playoffs.id,
  typeId: gameTypeId,
  placeId: arenaId, // FK to smrt-places (plain string, cross-package)
  startDate: new Date('2026-04-20T19:00:00'),
  endDate: new Date('2026-04-20T21:30:00'),
  status: 'scheduled',
});
await game.save();

await participants.create({
  eventId: game.id,
  profileId: lakersProfileId,
  role: 'home',
  placement: 0,
});
await participants.create({
  eventId: game.id,
  profileId: warriorsProfileId,
  role: 'away',
  placement: 1,
});

Owned Assets via EventAsset (new)

event_assets is the canonical join for event-owned assets (recordings, slide decks, photos, agendas). Use the helpers on Event and EventCollection; reserve generic AssetAssociation for cross-cutting / provenance links that don't belong to a single owner.

typescript
// addAsset takes an Asset instance, a relationship string, and an optional sortOrder
await game.addAsset(recordingAsset, 'recording', 0);

const recordings = await game.getAssets('recording');
await game.removeAsset(recordingAsset.id);

// Collection-level helpers take the event id explicitly
await events.addAsset(game.id, recordingAsset, 'recording', 0);
const byEvent = await events.getAssets(game.id, 'recording');

Core Models

Event (Hierarchical, STI)

typescript
class Event extends SmrtHierarchical {
  name: string
  seriesId?: string         // FK to EventSeries
  parentId?: string | null  // Self-referencing hierarchy UUID (no depth limit!)
  typeId: string            // FK to EventType
  placeId?: string          // FK to Place (cross-package plain string)
  description?: string
  startDate?: Date | null
  endDate?: Date | null
  status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed'
  round?: number | null     // Sequence/round number in series
  metadata?: string         // JSON string with get/set/update helpers

  // Hierarchy navigation (inherited from SmrtHierarchical)
  async getParent(): Promise<Event | null>
  async getChildren(): Promise<Event[]>
  async getAncestors(): Promise<Event[]>
  async getDescendants(): Promise<Event[]>
  async getRootEvent(): Promise<Event>
  async getHierarchy(): Promise<{ ancestors; current; descendants }>

  // Participants
  async getParticipants(): Promise<EventParticipant[]>

  // Owned assets via event_assets (positional args; addAsset takes an Asset instance)
  async getAssets(relationship?: string): Promise<Asset[]>
  async addAsset(asset: Asset, relationship?: string, sortOrder?: number): Promise<void>
  async removeAsset(assetId: string, relationship?: string): Promise<void>
}

EventSeries (Recurring)

typescript
class EventSeries extends SmrtObject {
  name: string
  typeId: string
  organizerId: string       // FK to Profile (cross-package plain string)
  recurrence?: RecurrencePattern
}

interface RecurrencePattern {
  frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'
  interval?: number
  count?: number
  until?: Date
  byDay?: string[]
  byMonthDay?: number[]
  byMonth?: number[]
}

EventParticipant

typescript
// conflictColumns: ['event_id', 'profile_id', 'role']
class EventParticipant extends SmrtObject {
  eventId: string           // FK to Event (cross-package plain string)
  profileId: string         // FK to Profile (cross-package plain string)
  role: string              // 'home' | 'away' | 'speaker' | 'panelist' | etc.
  placement?: number        // Numeric: 0=home, 1=away, or ranking position
  groupId?: string          // Logical grouping (e.g. team members in a game)
                            // NOT enforced at DB level
  metadata?: Record<string, any>
}

Hierarchical Events

typescript
// game → quarters → individual goal events
const q1 = await events.create({
  name: '1st Quarter',
  parentId: game.id,
  startDate: new Date('2026-04-20T19:00:00'),
  endDate: new Date('2026-04-20T19:12:00'),
  typeId: periodTypeId,
});
await q1.save();

const goal = await events.create({
  name: 'LeBron 3-pointer',
  parentId: q1.id,
  startDate: new Date('2026-04-20T19:05:23'),
  typeId: scoreTypeId,
  metadata: JSON.stringify({ points: 3, player: 'LeBron James' }),
});
await goal.save();

const ancestors = await goal.getAncestors(); // [q1, game]
const root = await goal.getRootEvent();      // game

Gotchas

  • No depth limit on event hierarchy — deep nesting can cause N+1 queries (load with getDescendants() in batch)
  • placement is numeric: used for both team ordering (0=home, 1=away) and rankings — context-dependent
  • groupId not enforced at DB level: for logical grouping only (e.g. team members in a game)
  • Optional tenancy with nullable tenantId
  • Metadata stored as JSON string with get/set/update helpers — graceful parse-error handling
  • Owned asset helpers: use Event.getAssets() / addAsset() / removeAsset() or the matching EventCollection wrappers instead of generic AssetAssociation

UI Components

The @happyvertical/smrt-events package includes a Svelte 5 component for displaying meeting information.

Available Components

Usage Example

svelte
<script>
  import { MeetingView } from '@happyvertical/smrt-events/svelte';

  const meeting = {
    id: 'meeting-123',
    name: 'Town Council Meeting',
    startDate: '2026-01-15T19:00:00',
    status: 'scheduled',
    agendaUrl: '/docs/agenda.pdf',
    minutesUrl: '/docs/minutes.pdf',
    videoUrl: 'https://youtube.com/watch?v=...'
  };
</script>

<MeetingView {meeting} calendarUrl="/calendar" />

Best Practices

DOs

  • Use event series for recurring events
  • Initialize default event types with initializeDefaults()
  • Use placement for home/away or speaker order — keep the convention consistent
  • Use event_assets (via addAsset()) for recordings, agendas, slide decks
  • Eager-load descendants in batch instead of recursing one level at a time

DON'Ts

  • Don't create circular hierarchies (parent referencing child)
  • Don't store large binary data in metadata — use event_assets
  • Don't transition completed events back to other states
  • Don't rely on groupId for referential integrity — it's not FK-enforced

Related Modules