@happyvertical/smrt-properties
Digital properties (websites, apps) with hierarchical zones for content and ad placement, plus AI summaries via the smrt-prompts registry.
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
npm install @happyvertical/smrt-propertiesQuick Start
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
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
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.
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
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 zonesGotchas
- Empty
allowedFormats= all formats: an empty array means no restrictions, NOT "no formats" - Zone dimensions nullable independently: check
hasDimensions()before usingwidth/height deleteZone(cascade=false): doesn't delete children — moves them to the parent (orphan pattern)- Optional tenancy on both
PropertyandZone
Best Practices
DOs
- Save properties before creating zones
- Use
getTree()over repeatedgetChildren()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
parentIdto 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
metadataif you plan to surfacesummarize()publicly