@happyvertical/smrt-properties

Digital properties (websites, apps) with hierarchical zones for content and ad placement, plus AI summaries via the smrt-prompts registry.

v0.29.34PropertiesZones

Overview

smrt-properties manages digital properties (websites, apps, publications) and their hierarchical zones for content and ad placement. Property is STI-enabled with domain, URL, and optional repository/owner links. Zone forms an arbitrarily nested tree within each property, with in-memory depth caching to prevent N+1 queries during tree operations.

Tenancy

Both Property and Zone use @TenantScoped({ mode: 'optional' }). Use ZoneCollection.findWithGlobals(tenantId) to include global zones with tenant ones — common for shared zone templates.

Installation

bash
npm install @happyvertical/smrt-properties

Quick Start

typescript
import { PropertyCollection, ZoneCollection } from '@happyvertical/smrt-properties';

const properties = await PropertyCollection.create({ db });
const zones = await ZoneCollection.create({ db });

// Create property
const site = await properties.create({
  name: 'Oak Creek News',
  domain: 'oakcreeknews.com',
  url: 'https://oakcreeknews.com',
  status: 'active',
});
await site.save();

// Create page zone
const homePage = await zones.create({
  propertyId: site.id,
  name: 'Home Page',
  type: 'page',
  path: '/',
});
await homePage.save();

// Create ad slot
const headerSlot = await zones.create({
  propertyId: site.id,
  parentId: homePage.id,
  name: 'Header Leaderboard',
  type: 'slot',
  width: 728,
  height: 90,
  allowedFormats: ['display', 'video'],
});
await headerSlot.save();

// Get zone tree (cached depth lookups, no N+1)
const tree = await zones.getTree(site.id);

Core Models

Property

typescript
class Property extends SmrtObject {
  name: string
  domain: string
  url: string
  status: 'active' | 'inactive' | 'pending'
  ownerId?: string          // Optional profile link
  repositoryId?: string     // Optional project link
  metadata?: Record<string, any>

  // ZoneCollection wrappers loaded lazily via dynamic import
  async getZones(): Promise<Zone[]>
  async getZoneTree(): Promise<ZoneTree>
  async createZone(options): Promise<Zone>

  // Convenience
  isActive(): boolean

  // AI -- registered with smrt-prompts (see Prompt Registry below)
  async summarize(): Promise<string>
}

Zone

typescript
class Zone extends SmrtObject {
  propertyId: string
  parentId?: string         // Self-referencing hierarchy
  name: string
  type: string              // 'page' | 'section' | 'slot' | 'container' | 'widget'
  path?: string             // URL path pattern
  selector?: string         // CSS selector
  width?: number            // Nullable INDEPENDENTLY -- check hasDimensions()
  height?: number
  allowedFormats?: string[] // Empty array = no restrictions
  metadata?: Record<string, any>

  // Hierarchy traversal
  async getAncestors(): Promise<Zone[]>
  async getDescendants(): Promise<Zone[]>
  async getFullPath(): Promise<string>

  // Helpers
  isFormatAllowed(format: string): boolean
  hasDimensions(): boolean
  getDimensionString(): string | null   // null when dimensions are unset
}

Prompt Registry

Property.summarize() is registered with @happyvertical/smrt-prompts so tenants can override the template, model, and params at runtime.

typescript
import { smrtPropertiesSummarizePrompt } from '@happyvertical/smrt-properties';

// key: 'smrtProperties.property.summarize'
//
// Variables passed to the AI provider (PII-conscious):
//   name, domain, description, status,
//   plus aggregate zone information (count + top-level zone names).
//
// Intentionally EXCLUDED:
//   ownerId, repositoryId, tenantId  (internal FKs)
//   metadata                         (may contain analytics IDs / private config)

See smrt-prompts for the override workflow.

ZoneCollection Key Methods

typescript
await zones.findByProperty(propertyId);
await zones.getTree(propertyId);          // builds nested structure (depth-cached)
await zones.getAncestors(zoneId);
await zones.getDescendants(zoneId);
await zones.moveZone(zoneId, newParentId?); // validates against descendant cycles
await zones.deleteZone(zoneId, cascade);    // cascade=false orphans children to parent
await zones.findWithGlobals(tenantId);      // tenant + global zones

Gotchas

  • Empty allowedFormats = all formats: an empty array means no restrictions, NOT "no formats"
  • Zone dimensions nullable independently: check hasDimensions() before using width/height
  • deleteZone(cascade=false): doesn't delete children — moves them to the parent (orphan pattern)
  • Optional tenancy on both Property and Zone

Best Practices

DOs

  • Save properties before creating zones
  • Use getTree() over repeated getChildren() calls — it's depth-cached
  • Use moveZone() for reparenting — it validates against descendant cycles
  • Validate formats with isFormatAllowed() before assignment
  • Override summarize() at the tenant level via smrt-prompts instead of subclassing

DON'Ts

  • Don't manually set parentId to a descendant — causes cycles
  • Don't delete properties without handling zones first
  • Don't query zones repeatedly in loops — use getTree() or batch operations
  • Don't put PII or sensitive analytics IDs in metadata if you plan to surface summarize() publicly

Related Modules