diff --git a/.context/admin-ui.md b/.context/admin-ui.md new file mode 100644 index 0000000000..7e60dba397 --- /dev/null +++ b/.context/admin-ui.md @@ -0,0 +1,348 @@ +# Admin UI: Data Manager, Applications & Navigation + +## Data Manager + +Data Manager provides automatic CRUD admin UI for any Preside Object. + +### Enable for an Object + +```cfml +/** + * @datamanagerEnabled true + * @datamanagerGroup blog + * @labelfield title + * @datamanagerGridFields title,author,published,datecreated + * @datamanagerSortableFields title,datecreated + * @datamanagerSearchFields title,body + * @datamanagerDefaultSortOrder datecreated desc + * @datamanagerAllowedOperations read,add,edit,delete,clone,viewversions + * @datamanagerAllowDrafts true + * @datamanagerModalView true + * @datamanagerTreeView true + * @datamanagerTreeParentProperty parent_category + * @datamanagerTreeSortOrder sort_order + */ +component { + property name="title" type="string" dbtype="varchar" maxlength="200" required=true; +} +``` + +**Allowed operations:** `read`, `add`, `edit`, `batchedit`, `delete`, `batchdelete`, `clone`, `viewversions` + +### i18n for Data Manager + +```properties +# /i18n/preside-objects/blog_post.properties +title=Blog Posts +title.singular=Blog Post +description=Manage blog posts +iconclass=fa-pencil + +field.title.title=Title +field.title.help=The blog post title +field.title.placeholder=Enter title + +field.published.title=Published +field.published.listing.title=Pub? # Short column heading +``` + +### Data Manager Groups + +```properties +# /i18n/preside-objects/groups/blog.properties +title=Blog +description=Blog content management +iconclass=fa-comments +``` + +### Form Conventions + +| Form | Path | +|------|------| +| Default (add + edit) | `/forms/preside-objects/{objectName}.xml` | +| Add only | `/forms/preside-objects/{objectName}/admin.add.xml` | +| Edit only | `/forms/preside-objects/{objectName}/admin.edit.xml` | +| Quick-add modal | `/forms/preside-objects/{objectName}/admin.quickadd.xml` | +| Quick-edit modal | `/forms/preside-objects/{objectName}/admin.quickedit.xml` | +| Clone | `/forms/preside-objects/{objectName}/admin.clone.xml` | +| Translate | `/forms/preside-objects/_translation_{objectName}/admin.edit.xml` | + +### Data Manager Customization Handler + +Create `/handlers/admin/datamanager/{objectName}.cfc` to override Data Manager behaviour: + +```cfml +component extends="preside.system.base.AdminHandler" { + + // Override form names + private string function getEditRecordFormName( event, rc, prc, args={} ) { + return "preside-objects.blog_post.admin.edit.#args.record.status#"; + } + + // Intercept before/after operations + private void function preEditRecordAction( event, rc, prc, args={} ) { + // Runs before save + } + + private void function postEditRecordAction( event, rc, prc, args={} ) { + // Runs after save — args.recordId available + clearSearchIndex( args.recordId ); + } + + private void function preAddRecordAction( event, rc, prc, args={} ) {} + private void function postAddRecordAction( event, rc, prc, args={} ) {} + private void function preDeleteRecordAction( event, rc, prc, args={} ) {} + private void function postDeleteRecordAction( event, rc, prc, args={} ) {} + + // Customise grid query + private void function preFetchRecordsForGridListing( event, rc, prc, args={} ) { + args.extraFilters = args.extraFilters ?: []; + args.extraFilters.append({ filter={ author=event.getAdminUserId() } }); + } + + // Custom action buttons per record + private array function getExtraRecordActionsForGridListing( event, rc, prc, args={} ) { + return [{ + link = event.buildAdminLink( linkTo="blog.preview", queryString="id=#args.record.id#" ) + , title = "Preview" + , iconClass = "fa-eye" + , target = "_blank" + }]; + } + + // Draft preview buttons + private array function getDraftPreviewActionButtons( event, rc, prc, args={} ) { + return [{ + title = "Preview on site" + , link = event.buildLink( linkTo="blog.view", queryString="id=#args.recordId#&draft=true" ) + , iconClass = "fa-globe" + , target = "_blank" + }]; + } +} +``` + +--- + +## Admin Applications + +Multiple separate admin applications can coexist (e.g. main CMS + Events Manager): + +```cfml +// Config.cfc +settings.adminApplications.append({ + id = "ems" + , feature = "ems" + , accessPermission = "ems.access" + , defaultEvent = "admin.ems.index" + , activeEventPattern = "^admin\.ems\..*" + , layout = "ems" // Uses /layouts/ems.cfm +}); + +settings.features.ems = { enabled=true }; +settings.adminPermissions.ems = [ "access" ]; +settings.adminRoles.eventsManager = [ "ems.*" ]; +``` + +Admin handler for the application: +```cfml +// /handlers/admin/Ems.cfc +component extends="preside.system.base.AdminHandler" { + + public void function preHandler( event, action, eventArguments ) { + super.preHandler( argumentCollection=arguments ); + if ( !isFeatureEnabled("ems") ) { event.notFound(); } + if ( !hasCmsPermission("ems.access") ) { event.adminAccessDenied(); } + prc.pageIcon = "fa-calendar"; + } + + public void function index( event, rc, prc ) { + prc.pageTitle = "Events Manager"; + event.setView( "admin/ems/index" ); + } +} +``` + +--- + +## Admin Left-Hand Navigation (v10.17.0+) + +### Configure Menu Items in Config.cfc + +```cfml +settings.adminSideBarItems = [ + "sitetree" + , "assetmanager" + , "datamanager" + , "usermanager" + , "websiteUserManager" + , "systemConfiguration" + , "updateManager" + , "myCustomItem" // Add custom items +]; + +settings.adminMenuItems.myCustomItem = { + feature = "myFeature" + , permissionKey = "myfeature.access" + , activeChecks = { handlerPatterns="^admin\\.myfeature\\..*" } + , buildLinkArgs = { linkTo="admin.myfeature.index" } + , gotoKey = "m" + , icon = "fa-star" + , title = "myapp:admin.nav.myfeature" + , subMenuItems = [ "mySubItem" ] +}; + +settings.adminMenuItems.mySubItem = { + activeChecks = { datamanagerObject="my_object" } + , buildLinkArgs = { linkTo="datamanager.object", queryString="object=my_object" } + , title = "myapp:admin.nav.myobject" +}; +``` + +### Dynamic Menu Item Handler +```cfml +// /handlers/admin/layout/menuitem/myCustomItem.cfc +component { + private boolean function isActive( args={} ) { return false; } + private string function buildLink( args={} ) { + return event.buildAdminLink( linkTo="myfeature.index" ); + } + private void function prepare( args={} ) { + // Dynamically add children + args.subMenuItems.append( getModel("myService").getDynamicNavItems(), true ); + } +} +``` + +### Legacy Sidebar View (pre-v10.17.0) +```cfm + + + + #renderView( view="/admin/layout/sidebar/_menuItem", args={ + active = ReFindNoCase( "^admin\.myfeature", event.getCurrentEvent() ) + , title = translateResource( "myapp:admin.nav.myfeature" ) + , link = event.buildAdminLink( linkTo="myfeature.index" ) + , icon = "fa-star" + } )# + + +``` + +--- + +## Admin System Configuration Menu + +```cfml +// Config.cfc +settings.adminConfigurationMenuItems = [ + "usermanager" + , "systemConfiguration" + , "taskmanager" + , "errorLogs" + , "auditTrail" + , "myConfigSection" // Add custom items +]; +``` + +Legacy view for custom config menu item: +```cfm + + + +
  • + + + #translateResource("myapp:admin.config.myfeature")# + +
  • +
    +
    +``` + +--- + +## Admin Handler Conventions + +All admin handlers extend `preside.system.base.AdminHandler`: + +```cfml +component extends="preside.system.base.AdminHandler" { + + public void function preHandler( event, action, eventArguments ) { + super.preHandler( argumentCollection=arguments ); + // super.preHandler checks login, sets layout, etc. + + // Add breadcrumb + event.addAdminBreadCrumb( + title = translateResource( "myapp:breadcrumb.title" ) + , link = event.buildAdminLink( linkTo="myfeature.index" ) + ); + } + + public void function index( event, rc, prc ) { + prc.pageTitle = translateResource( "myapp:page.title" ); + prc.pageSubTitle = translateResource( "myapp:page.subtitle" ); + prc.pageIcon = "fa-star"; + + event.setView( "admin/myfeature/index" ); + } + + // Action handlers (names must end with "Action" for CSRF protection) + public void function saveAction( event, rc, prc ) { + var formData = event.getCollectionForForm( "my.form" ); + var vr = validateForm( "my.form", formData ); + + if ( !vr.validated() ) { + setNextEvent( + url = event.buildAdminLink( linkTo="myfeature.index" ) + , persistStruct = { validationResult=vr, formData=formData } + ); + } + + getModel("myService").save( formData ); + setNextEvent( url=event.buildAdminLink( linkTo="myfeature.index" ) ); + } +} +``` + +### Key Admin Event Methods + +```cfml +event.buildAdminLink( linkTo="handler.action", queryString="id=x" ) +event.addAdminBreadCrumb( title="Title", link=url ) +event.adminAccessDenied() +event.getAdminUserId() +event.isAdminRequest() +hasCmsPermission( "permission.key" ) +translateResource( "bundle:key" ) +renderViewlet( event="admin.myhandler.myviewlet", args={} ) +``` + +--- + +## Editable System Settings + +Configurable settings stored in DB and editable via the admin: + +```cfml +// 1. Form: /forms/system-config/my-settings.xml +// 2. i18n: /i18n/system-config/my-settings.properties +// name=My Settings +// description=Configure my feature + +// 3. (Optional) Register in system config menu via Config.cfc +``` + +Retrieve settings: +```cfml +// In handlers/views: +var apiKey = getSystemSetting( category="my-settings", setting="api_key", default="" ); + +// In services: +var apiKey = $getPresideSetting( "my-settings", "api_key", "default" ); +var allSettings = $getPresideCategorySettings( "my-settings" ); + +// Via WireBox DSL: +property name="apiKey" inject="presidecms:systemsetting:my-settings.api_key"; +``` diff --git a/.context/architecture.md b/.context/architecture.md new file mode 100644 index 0000000000..8e4c031c34 --- /dev/null +++ b/.context/architecture.md @@ -0,0 +1,377 @@ +# Architecture, Config, Extensions & Feature Flags + +## Bootstrap & Application Startup + +`/system/Bootstrap.cfc` orchestrates startup via `setupApplication()`. Modern apps extend it directly in `Application.cfc`: + +```cfml +// /Application.cfc +component extends="preside.system.Bootstrap" { + this.PRESIDE_APPLICATION_ID = "myapp"; + this.PRESIDE_APPLICATION_RELOAD_TIMEOUT = 1200; + this.PRESIDE_APPLICATION_RELOAD_LOCK_TIMEOUT = 0; + + super.setupApplication( + id = this.PRESIDE_APPLICATION_ID + , presideSessionManagement = false // optional + , sessionTimeout = CreateTimeSpan( 0, 0, 40, 0 ) + ); +} +``` + +**Startup sequence:** +1. CF mappings established (`/preside`, `/coldbox`, `/app`, `/assets`, `/logs`) +2. `_fetchInjectedSettings()` — loads `.env`, `.injectedConfiguration`, OS env vars +3. ColdBox initialised via custom Bootstrap +4. WireBox maps all services, interceptors registered +5. `postPresideReload` interception fired + +**Key interception points:** +- `prePresideReload` / `postPresideReload` +- `onApplicationStart` / `onApplicationEnd` +- `preProcess` / `postProcess` + +--- + +## Config.cfc + +Extends `preside.system.config.Config` and calls `super.configure()`: + +```cfml +// /application/config/Config.cfc +component extends="preside.system.config.Config" { + public void function configure() { + super.configure(); + + // Override ColdBox settings + coldbox.appName = "My Application"; + coldbox.reinitPassword = "mySecurePassword"; + + // Feature flags + settings.features.myFeature = { enabled=true }; + + // Admin permissions + settings.adminPermissions.myapp = [ "access", "manage" ]; + settings.adminRoles.myRole = [ "myapp.*" ]; + + // Navigation + settings.adminSideBarItems.append( "myFeatureNav" ); + } + + // Environment-specific overrides + public void function local() { + settings.showErrors = true; + settings.autoSyncDb = true; + settings.developerMode = true; + } +} +``` + +### Environment Config Injection + +Three mechanisms (processed in order, last wins): + +1. **`.env` file** (project root): + ``` + PRESIDE_syncDb=false + PRESIDE_forceSsl=true + ``` + +2. **JSON file** (`/application/config/.injectedConfiguration`): + ```json + { "syncDb": false, "forceSsl": true } + ``` + +3. **OS environment variables** with `PRESIDE_` prefix. + +Access injected values via `settings.env.myKey`. + +### Developer Mode (per-request reload) + +```cfml +// In local() method: +settings.developerMode = true; // Reload everything + +// Or selectively: +settings.developerMode = { + dbSync = true + , flushCaches = true + , reloadForms = true + , reloadStatic = true + , reloadI18n = true + , reloadPresideObjects = true + , reloadWidgets = true + , reloadPageTemplates = true +}; +``` + +--- + +## Directory Structure + +``` +/application + /config + Config.cfc # Main config (extends preside.system.config.Config) + LocalConfig.cfc # Local dev overrides (gitignored) + Wirebox.cfc # Custom DI mappings (optional) + Cachebox.cfc # Custom cache config (optional) + Routes.cfm # Custom URL routes (optional) + /preside-objects/ # CFC data object definitions + /handlers/ # ColdBox event handlers + /admin/ # Admin-area handlers + /page-types/ # Page type handlers + /widgets/ # Widget handlers + /formcontrols/ # Custom form controls + /renderers/ # Label/content renderers + /dataExporters/ # Data export handlers + /email/ # Email template handlers + /rules/ # Rules engine expressions/contexts + /Tasks.cfc # Scheduled task definitions + /SelectDataViews.cfc # Named data queries + /DataFilters.cfc # Named data filters + /services/ # Business logic CFCs + /views/ # CFM view templates + /forms/ # XML form definitions + /preside-objects/ # Forms for data objects + /page-types/ # Forms for page types + /widgets/ # Widget config forms + /i18n/ # .properties resource bundles + /preside-objects/ # Object field labels + /widgets/ # Widget labels + /email/ # Email template strings + /roles.properties # Role names + /permissions.properties + /layouts/ # Page layout CFMs + /helpers/ # UDF helper files + /extensions/ # Third-party extension packages + /extensions_app/ # App-local extensions +.env # Environment variables (gitignored) +``` + +--- + +## Extension System + +Extensions are self-contained packages of Preside functionality. + +### Extension Structure +``` +/my-extension/ + manifest.json # REQUIRED metadata + box.json # CommandBox package metadata + ModuleConfig.cfc # Optional: ColdBox module config + /config + Config.cfc # Extension config (configure(config) signature) + Wirebox.cfc # Extension DI mappings (configure(binder) signature) + /preside-objects/ # Data objects + /handlers/ # Handlers + /services/ # Services + /views/ # Views + /forms/ # Form definitions + /i18n/ # Resource bundles +``` + +### manifest.json +```json +{ + "id": "preside-ext-my-extension", + "title": "My Extension", + "author": "Company Name", + "version": "1.0.0+0001", + "dependsOn": ["preside-ext-another-ext"] +} +``` + +### Extension Config.cfc +Note: uses `configure(config)` signature, NOT `configure()`: +```cfml +component { + public void function configure( required struct config ) { + var settings = config.settings ?: {}; + + // Add feature flags + settings.features.myExtFeature = { enabled=true, dependsOn=["admin"] }; + + // Add permissions + settings.adminPermissions.myext = [ "access", "manage" ]; + + // Add interceptors + config.interceptors.append({ + class = "app.extensions.my-extension.interceptors.MyInterceptor" + }); + } +} +``` + +### ColdBox Module in an Extension + +Add `ModuleConfig.cfc` to make the extension a ColdBox module. This allows namespaced injection: +```cfml +// ModuleConfig.cfc +component { + this.cfmapping = "myext"; // Allows: getInstance("myext:MyService") + this.autoMapModels = true; + this.modelNamespace = "myext"; +} +``` + +--- + +## Feature Flags + +Feature flags are compile-time switches — they affect which files load, not just runtime behaviour. + +### Defining Features +```cfml +// In Config.cfc: +settings.features.myFeature = { + enabled = true + , dependsOn = [ "admin" ] // parent features (v10.27+) + , widgets = [ "myWidget" ] // widgets requiring this feature + , siteTemplates = [ "*" ] // which site templates expose it +}; +``` + +### Applying Features + +**Preside Object:** +```cfml +/** @feature myFeature */ +component { ... } + +// On a single property: +property name="x" feature="myOtherFeature"; +``` + +**Handler or Service:** +```cfml +/** @feature myFeature || anotherFeature */ +component { ... } +``` + +**View (first line):** +```cfm + +``` + +**Form element:** +```xml +
    + + + +
    +``` + +### Feature-Dependent Injection +```cfml +property name="optService" inject="featureInjection:myFeature:OptionalService"; + +function doThing() { + if ( $isFeatureEnabled("myFeature") ) { + optService.doThing(); + } +} +``` + +### .presideIgnore.json +Optimize startup by skipping disabled-feature files. In `Config.cfc`: +```cfml +// Dev: write the file on startup +settings.ignoreFile.read = false; +settings.ignoreFile.write = true; + +// Prod: read the pre-generated file +settings.ignoreFile.read = true; +settings.ignoreFile.write = false; +``` + +--- + +## WireBox Dependency Injection + +### Service Auto-Discovery +Services in `/services/`, extension `/services/`, and `/system/services/` are auto-mapped as singletons. + +### Injection Annotations +```cfml +/** + * @presideService true -- injects PresideSuperClass helpers + * @singleton true -- (default anyway) + * @feature myFeat -- only map if feature enabled + * @nowirebox true -- exclude from auto-mapping + */ +component { + public any function init( + /** + * @someService.inject someService + * @objectDao.inject presidecms:object:my_object + * @cache.inject cachebox:MyCache + * @setting.inject coldbox:setting:myKey + * @lazyDep.inject delayedInjector:otherService + * @optDep.inject featureInjection:myFeature:OptService + */ + required any someService, + required any objectDao + // ... + ) { + _setSomeService( arguments.someService ); + return this; + } +} +``` + +### Custom WireBox Mappings +```cfml +// /application/config/Wirebox.cfc +component extends="preside.system.config.Wirebox" { + public void function configure() { + super.configure(); + + map("mySpecialService") + .asSingleton() + .to("app.services.MySpecialService") + .initArg( name="rootDir", value=expandPath("/uploads") ); + } +} +``` + +--- + +## Interception (Event System) + +Register interceptors in `Config.cfc`: +```cfml +settings.interceptors.append({ + class = "app.interceptors.MyInterceptor", + properties = {} +}); +``` + +Interceptor CFC: +```cfml +component extends="coldbox.system.Interceptor" { + public void function configure() {} + + public void function postInsertObjectData( event, interceptData ) { + var objectName = interceptData.objectName ?: ""; + if ( objectName == "blog_post" ) { + // React to new blog post + } + } +} +``` + +### Key Interception Points +| Category | Points | +|----------|--------| +| App lifecycle | `prePresideReload`, `postPresideReload` | +| DB operations | `preInsertObjectData`, `postInsertObjectData`, `preUpdateObjectData`, `postUpdateObjectData`, `preDeleteObjectData`, `postDeleteObjectData`, `preSelectObjectData`, `postSelectObjectData` | +| Auth | `onLoginSuccess`, `onLoginFailure`, `onLogout` | +| REST | `onRestRequest`, `preInvokeRestResource`, `postInvokeRestResource` | +| Email | `preSendEmail`, `postSendEmail`, `onPrepareEmailSendArguments` | +| Site tree | `preRenderSiteTreePage`, `postRenderSiteTreePage` | +| Form builder | `preFormBuilderFormSubmission`, `postFormBuilderFormSubmission` | +| Config | `preSaveSystemConfig` | +| File download | `preDownloadFile` | diff --git a/.context/asset-manager.md b/.context/asset-manager.md new file mode 100644 index 0000000000..f96234e275 --- /dev/null +++ b/.context/asset-manager.md @@ -0,0 +1,327 @@ +# Asset Manager + +## Configuration + +```cfml +// Config.cfc +public void function configure() { + super.configure(); + + // File size limits + settings.assetmanager.maxFileSize = 10; // MB, default + + // Add custom file types + settings.assetmanager.types.document.pdf = { + serveAsAttachment = true + , mimeType = "application/pdf" + }; + + settings.assetmanager.types.video.ogv = { + serveAsAttachment = false + , mimeType = "video/ogg" + }; + + // Image derivatives (auto-generated versions) + settings.assetmanager.derivatives.leadimage = { + permissions = "inherit" // "inherit" or "public" + , inEditor = true // Selectable in admin editor + , autoQueue = [ "image" ] // Auto-generate for these types + , transformations = [ + { method="resize", args={ width=1200, height=600, maintainAspectRatio=true } } + ] + }; + + settings.assetmanager.derivatives.thumbnail = { + permissions = "public" + , inEditor = false + , autoQueue = [ "image" ] + , transformations = [ + { method="shrinkToFit", args={ width=200, height=200 } } + ] + }; + + settings.assetmanager.derivatives.pdfpreview = { + permissions = "public" + , autoQueue = [ "document" ] + , transformations = [ + { method="pdfPreview", args={ page=1 }, inputfiletype="pdf", outputfiletype="jpg" } + ] + }; + + // Pre-created folder structure + settings.assetmanager.folders.profileImages = { + label = "Profile Images" + , hidden = false + , children = { + members = { label="Members", hidden=false } + , nonMembers = { label="Non-Members", hidden=false } + } + }; + + // Storage paths (typically set in environment config) + settings.assetmanager.location.public = ExpandPath( "/uploads/public" ); + settings.assetmanager.location.private = ExpandPath( "/uploads/private" ); + settings.assetmanager.location.trash = ExpandPath( "/uploads/.trash" ); + settings.assetmanager.location.publicUrl = "//cdn.mysite.com/"; + + // Processing queue (v10.11.0+) + settings.features.assetQueue.enabled = true; + settings.features.assetQueueHeartBeat.enabled = true; + settings.assetmanager.queue.concurrency = 4; + settings.assetmanager.queue.batchSize = 100; +} +``` + +--- + +## Image Transformations + +Built-in transformation methods: + +```cfml +// shrinkToFit — scale to fit within box, maintain aspect ratio +{ method="shrinkToFit", args={ width=200, height=200, quality="highPerformance" } } +// quality options: "highPerformance", "highQuality", "nearest", "bilinear", "bicubic" + +// resize — crop/resize to exact dimensions +{ method="resize", args={ width=800, height=400, maintainAspectRatio=true } } + +// pdfPreview — render a PDF page as an image +{ method="pdfPreview", args={ page=1 }, inputfiletype="pdf", outputfiletype="jpg" } +``` + +### Custom Transformation + +```cfml +// Config.cfc +settings.assetmanager.derivatives.watermarked = { + transformations = [ + { method="resize", args={ width=1200 } } + , { method="watermark", args={ opacity=0.3 } } + ] +}; + +// /handlers/AssetTransformers.cfc +component { + property name="imageManipulationService" inject="imageManipulationService"; + + private binary function watermark( event, rc, prc, args={} ) { + // args.asset = binary of the image + // args.opacity = from derivative config + var img = ImageNew( args.asset ); + // ... apply watermark logic + var result = ImageGetBlob( img, "jpg" ); + return result; + } +} +``` + +--- + +## Using Assets in Preside Objects + +```cfml +// Single image +property name="profile_image" relationship="many-to-one" relatedTo="asset" + allowedTypes="image"; + +// Multiple files +property name="attachments" relationship="many-to-many" relatedTo="asset" + allowedTypes="document,pdf"; + +// In admin form (auto-resolved from property): +// + +// Explicit form control: +// +``` + +--- + +## Building Asset URLs + +```cfml +// Basic asset URL +event.buildLink( assetId=myRecord.profile_image ) + +// With derivative +event.buildLink( assetId=myRecord.profile_image, derivative="thumbnail" ) + +// Specific version +event.buildLink( assetId=myRecord.profile_image, versionId=versionId ) + +// From a service (PresideSuperClass): +var url = $buildLink( assetId=assetId, derivative="thumbnail" ) +``` + +--- + +## Rendering Assets + +```cfm + +#renderAsset( + assetId = myRecord.profile_image + , context = "mainContent" // Context for derivative selection + , args = { derivative="leadimage", class="hero-image", alt="Page hero" } +)# +``` + +### Configuring renderAsset Contexts + +```cfml +// Config.cfc +settings.assetmanager.assetContexts.mainContent = { + derivatives = [ "leadimage", "thumbnail" ] + , defaultDerivative = "leadimage" +}; +``` + +--- + +## Custom Storage Providers + +```cfml +// /services/fileStorage/S3StorageProvider.cfc +component implements="preside.system.services.fileStorage.StorageProvider" { + + public any function init( + required string accessKey + , required string secretKey + , required string bucketName + , required string region + ) { + variables.s3 = createS3Client( argumentCollection=arguments ); + return this; + } + + public binary function getObject( required string path ) { + return variables.s3.getObject( bucket=variables.bucketName, key=arguments.path ); + } + + public string function putObject( + required binary object + , required string path + , string mimeType = "" + , boolean isPrivate = false + ) { + variables.s3.putObject( + bucket = variables.bucketName + , key = arguments.path + , body = arguments.object + , acl = arguments.isPrivate ? "private" : "public-read" + ); + return arguments.path; + } + + public boolean function deleteObject( required string path ) { + variables.s3.deleteObject( bucket=variables.bucketName, key=arguments.path ); + return true; + } + + public boolean function objectExists( required string path ) { + return variables.s3.objectExists( bucket=variables.bucketName, key=arguments.path ); + } + + public string function getObjectUrl( required string path ) { + return "https://#variables.bucketName#.s3.amazonaws.com#arguments.path#"; + } +} + +// Register provider in Config.cfc: +settings.storageProviders.s3 = { + class = "app.services.fileStorage.S3StorageProvider" +}; + +// Form for admin UI config: /forms/storage-providers/s3.xml +``` + +--- + +## Asset Manager Service + +```cfml +property name="assetManagerService" inject="assetManagerService"; + +// List derivatives +var derivatives = assetManagerService.listDerivatives(); +var editorDerivs = assetManagerService.listEditorDerivatives(); + +// Queue derivative generation +assetManagerService.queueAssetDerivatives( + assetId = assetId + , derivative = "thumbnail" +); + +// Get asset metadata +var asset = assetManagerService.getAssetData( assetId ); +// Returns struct: { id, title, filename, type, filesize, ... } + +// Get asset dimensions (image) +var dimensions = assetManagerService.getAssetDimensions( + id = assetId + , derivativeName = "thumbnail" +); +// Returns: { width=200, height=150 } + +// Upload an asset programmatically +var assetId = assetManagerService.addAsset( + filePath = tmpFilePath + , fileName = "myfile.pdf" + , folder = folderIdOrSlug + , assetData = { title="My Document", description="..." } +); +``` + +--- + +## File Download Interception + +Control access to private/protected assets: + +```cfml +// /interceptors/AssetAccessControl.cfc +component extends="coldbox.system.Interceptor" { + + property name="websiteLoginService" inject="provider:websiteLoginService"; + property name="permService" inject="provider:websitePermissionService"; + + public void function configure() {} + + public void function preDownloadFile( event, interceptData ) { + var storageProvider = event.getValue( "storageProvider", "" ); + + if ( storageProvider == "privateAssets" ) { + if ( !websiteLoginService.isLoggedIn() ) { + event.accessDenied( reason="LOGIN_REQUIRED" ); + } + + var userId = websiteLoginService.getLoggedInUserId(); + if ( !permService.hasPermission( permissionKey="assets.access", userId=userId ) ) { + event.accessDenied( reason="INSUFFICIENT_PRIVILEGES" ); + } + } + } +} +``` + +--- + +## Asset Picker Form Control Options + +```xml + + + +``` diff --git a/.context/caching.md b/.context/caching.md new file mode 100644 index 0000000000..0fd6ac7be4 --- /dev/null +++ b/.context/caching.md @@ -0,0 +1,211 @@ +# Caching + +## CacheBox Configuration + +Preside configures CacheBox in `/system/config/Cachebox.cfc`. Override in `/application/config/Cachebox.cfc`: + +```cfml +// /application/config/Cachebox.cfc +component extends="preside.system.config.Cachebox" { + + public void function configure() { + super.configure( argumentCollection=arguments ); + + // Customise page cache + cacheBox.caches.PresidePageCache.properties.maxObjects = 50000; + cacheBox.caches.PresidePageCache.properties.objectDefaultTimeout = 3600; + cacheBox.caches.PresidePageCache.properties.evictionPolicy = "LFU"; + + // Add custom cache + cacheBox.caches.MyFeatureCache = { + provider = "coldbox.system.cache.providers.CacheBoxColdBoxProvider" + , properties = { + maxObjects = 1000 + , objectDefaultTimeout = 300 + , evictionPolicy = "LFU" + , objectStore = "ConcurrentStore" + } + }; + } +} +``` + +## Built-in Cache Names + +| Cache Name | Purpose | +|------------|---------| +| `default` | General object cache | +| `template` | ColdBox template cache | +| `DefaultQueryCache` | Query result cache | +| `PermissionsCache` | Admin permission cache | +| `PresidePageCache` | Full rendered page cache | +| `PresideRequestCache` | Per-request transient cache | + +## Using a Cache in a Service + +```cfml +component { + property name="cache" inject="cachebox:MyFeatureCache"; + + function getExpensiveData( required string key ) { + var cached = cache.get( arguments.key ); + if ( !IsNull( cached ) ) { + return cached; + } + + var data = _doExpensiveWork( arguments.key ); + cache.set( arguments.key, data, 300 ); // 300 second TTL + return data; + } + + function clearCache( required string key ) { + cache.clear( arguments.key ); + } + + function clearAllCache() { + cache.clearAll(); + } +} +``` + +## Query Caching in selectData + +```cfml +// Queries are cached by default +blogPostDao.selectData( useCache=true ) + +// Custom cache timeout (seconds) +blogPostDao.selectData( useCache=true, cacheTimeout=600 ) + +// Disable caching +blogPostDao.selectData( useCache=false ) +``` + +## Clearing Caches + +Via reload tokens: +``` +/?fwReinitCaches=true # Clear all caches +/?fwReinitDbSync=true # Sync DB + clear object caches +/?fwReinitObjects=true # Reload object definitions +``` + +Programmatically: +```cfml +// Clear the query cache +cacheBox.getCache("DefaultQueryCache").clearAll(); + +// Clear specific cache key +cacheBox.getCache("PresidePageCache").clear("/about-us/"); +``` + +--- + +## Full Page Caching + +Caches entire rendered HTML pages. Serves cached HTML without executing CFML on subsequent requests. + +### Enable + +```cfml +// Config.cfc +settings.features.fullPageCaching.enabled = true; + +// Optional: also cache for logged-in users +settings.features.fullPageCachingForLoggedInUsers.enabled = true; + +// Limit what PRC data is saved with cache entry (reduces memory) +settings.fullPageCaching.limitCacheData = true; +settings.fullPageCaching.limitCacheDataKeys.prc = [ + "_site" + , "presidePage" + , "currentLayout" + , "__presideInlineJs" + , "_presideUrlPath" +]; +``` + +### Disable Caching Per Request + +```cfml +// In a handler or widget: +event.cachePage( false ); // Disable for this request +event.preventPageCache(); // Alternative + +// Set custom TTL +event.setPageCacheTimeout( 3600 ); // Seconds + +// In a widget annotation: +/** @cacheable false */ +private string function index( event, rc, prc, args={} ) { ... } +``` + +### Delayed Viewlets (Personalised Regions) + +Viewlets marked `delayed=true` are rendered AFTER the cached page is served, allowing personalised content to be injected into cached pages: + +```cfm + + + +
    + #renderViewlet( event="page-types.standard.index", args=args )# +
    + + +
    + #renderViewlet( event="widgets.UserGreeting", args={}, delayed=true )# +
    + + +#renderViewlet( event="core.navigation.mainNavigation", delayed=true )# +``` + +The delayed viewlets mechanism: +1. Page HTML is served from cache +2. Placeholders are left for delayed viewlets +3. Delayed viewlets are then executed and output is inserted + +### Full Page Cache Bypass Conditions + +The page cache is automatically bypassed when: +- Request is a POST +- URL contains `fwreinit` or other reload params +- `event.cachePage( false )` has been called +- Widget or handler has `@cacheable false` +- Admin user is viewing the page (unless `fullPageCachingForLoggedInUsers` is enabled) + +--- + +## Request Cache + +Short-lived per-request cache to avoid duplicate DB calls within a single request: + +```cfml +component { + property name="requestCache" inject="cachebox:PresideRequestCache"; + + function getCurrentUser() { + var cached = requestCache.get( "currentUser" ); + if ( !IsNull(cached) ) { return cached; } + + var user = userService.getLoggedInUser(); + requestCache.set( "currentUser", user ); + return user; + } +} +``` + +--- + +## CKEditor Template Cache + +Static assets (CSS, JS) are fingerprinted by default. During development, reload them: +``` +/?fwReinitStatic=true +``` + +Or enable auto-reload in developer mode: +```cfml +settings.developerMode = { reloadStatic=true }; +``` diff --git a/.context/data-objects.md b/.context/data-objects.md new file mode 100644 index 0000000000..6a742b3e30 --- /dev/null +++ b/.context/data-objects.md @@ -0,0 +1,428 @@ +# Preside Data Objects (ORM) + +Preside Objects are CFC files in `/preside-objects/` that define database tables and get an automatic CRUD service. No traditional Hibernate ORM is used. + +## Basic Object Definition + +```cfml +// /preside-objects/blog_post.cfc +/** + * @labelfield title + */ +component { + property name="title" type="string" dbtype="varchar" maxLength="200" required=true; + property name="slug" type="string" dbtype="varchar" maxLength="200" required=true uniqueindexes="slug"; + property name="body" type="string" dbtype="text"; + property name="published" type="boolean" dbtype="boolean" default=false; + property name="publish_date" type="date" dbtype="datetime"; + property name="author" relationship="many-to-one" relatedTo="security_user"; + property name="categories" relationship="many-to-many" relatedTo="blog_category"; +} +``` + +Table is automatically created/synced as `pobj_blog_post`. Four properties are always auto-added: +- `id` (varchar 35, UUID, PK) +- `label` (varchar 250) — driven by `@labelfield` +- `datecreated` (datetime) +- `datemodified` (datetime) + +## Property Attributes + +| Attribute | Description | +|-----------|-------------| +| `name` | Field name (required) | +| `type` | CFML type: `string`, `numeric`, `boolean`, `date` | +| `dbtype` | DB column type: `varchar`, `int`, `text`, `boolean`, `datetime`, `decimal`, `bigint` | +| `maxLength` | Required for varchar fields | +| `required` | NOT NULL constraint | +| `default` | Static value, or `cfml:Now()`, or `method:myMethod` | +| `indexes` | e.g. `"idx1,idx2|1"` (compound: `"slug|1"` + `"slug_parent|2"`) | +| `uniqueindexes` | Unique constraint | +| `control` | Form control: `textinput`, `textarea`, `richeditor`, `select`, etc. | +| `renderer` | Content renderer name | +| `formula` | SQL formula for computed field | +| `generator` | Auto-value: `UUID`, `slug`, `timestamp`, `nextint`, `hash`, `method:myFunc` | +| `generate` | When: `insert`, `always`, `never` | +| `generateFrom` | Source property for slug generator | +| `enum` | Enum type defined in `settings.enum.myType` | +| `feature` | Only include if feature enabled | +| `cloneable` | Include in cloning (default: true for most fields) | +| `autoTrim` | Trim on save | +| `ignoreChangesForVersioning` | Don't version this field's changes | + +## Component Annotations + +```cfml +/** + * @labelfield title + * @nolabel true // No label field + * @tablename custom_name // Override table name + * @tableprefix myapp_ // Override prefix + * @versioned false // Disable versioning + * @versionOnInsert false // Don't version on insert + * @feature myFeature + * @defaultFilters activeOnly // Always apply these saved filters + * @datamanagerEnabled true + * @datamanagerGridFields id,title,datecreated + * @datamanagerDefaultSortOrder title asc + * @datamanagerAllowedOperations read,add,edit,delete,clone + * @datamanagerGroup mygroup // Group in Data Manager nav + * @cloneable true + * @labelRenderer myRenderer + * @tenant site // Data tenancy + */ +component { ... } +``` + +## Relationships + +### Many-to-One (FK column in this table) +```cfml +property name="category" relationship="many-to-one" relatedTo="blog_category" required=true; +``` +Creates `category` varchar(35) FK column in this table. + +### One-to-Many (no column here, traverse the other side) +```cfml +// On blog_category.cfc: +property name="posts" relationship="one-to-many" relatedTo="blog_post" relationshipKey="category"; +``` + +### Many-to-Many (auto pivot table) +```cfml +property name="categories" relationship="many-to-many" relatedTo="blog_category"; +// Creates pivot table: pobj_blog_post__join__blog_category + +// Custom pivot table name: +property name="tags" relationship="many-to-many" relatedTo="tag" relatedVia="post_tags"; + +// Multiple M2M to same object — must use relatedVia: +property name="primary_tags" relationship="many-to-many" relatedTo="tag" relatedVia="post_primary_tags"; +property name="secondary_tags" relationship="many-to-many" relatedTo="tag" relatedVia="post_secondary_tags"; +``` + +### SelectData View Relationship (v10.11.0+) +```cfml +property name="active_posts" relationship="select-data-view" relatedTo="activeBlogPosts" relationshipKey="category"; +``` + +## CRUD Service API + +Inject the object DAO directly, or use `presideObjectService`: + +```cfml +// Direct DAO injection (preferred) +property name="blogPostDao" inject="presidecms:object:blog_post"; + +// Or via service +property name="presideObjectService" inject="presideObjectService"; + +// selectData +var posts = blogPostDao.selectData( + selectFields = [ "id", "title", "category.label as cat_name", "Count(comments.id) as comment_count" ] + , filter = { published=true } + , orderBy = "publish_date desc" + , maxRows = 10 + , startRow = 1 + , useCache = true + , groupBy = "id" +); + +// insertData — returns new record ID +var newId = blogPostDao.insertData( data={ + title = "My Post" + , body = "Content here" + , author = authorId + , categories = [ cat1Id, cat2Id ] // M2M as array +}); + +// updateData +blogPostDao.updateData( + data = { title="Updated Title", published=true } + , filter = { id=postId } +); + +// deleteData +blogPostDao.deleteData( id=postId ); +// or: blogPostDao.deleteData( filter={ published=false } ); + +// dataExists +var exists = blogPostDao.dataExists( filter={ slug="my-post" } ); + +// selectCount +var total = blogPostDao.selectData( selectFields=["Count(*) as total"] ).total; +``` + +## Filtering + +### Simple struct filter +```cfml +blogPostDao.selectData( filter={ published=true, author=authorId } ); +``` + +### SQL filter with params +```cfml +blogPostDao.selectData( + filter = "published = :published and publish_date > :minDate" + , filterParams = { + published = { type="bit", value=true } + , minDate = { type="timestamp", value=Now() } + } +); +``` + +### Cross-relationship filter (dot notation) +```cfml +// Filter by related object field +blogPostDao.selectData( filter={ "category.active"=true } ); + +// Multi-level +blogPostDao.selectData( filter={ "category$parent.featured"=true } ); +``` + +### Extra filters (array of filter structs) +```cfml +blogPostDao.selectData( + extraFilters = [ + { filter={ published=true } } + , { filter="publish_date > :now", filterParams={ now=Now() } } + ] +); +``` + +### Named (saved) filters +Defined in `Config.cfc`: +```cfml +settings.filters.publishedPosts = { + filter = "published = :published and publish_date <= :now" + , filterParams = { published=true, now=Now() } +}; +// Or as a function: +settings.filters.publishedPosts = function( args={}, cbController ) { + return { filter={ published=true } }; +}; +``` +Or in `/handlers/DataFilters.cfc`: +```cfml +component { + private struct function publishedPosts( event, rc, prc, args={} ) { + return { filter={ published=true } }; + } +} +``` +Use: +```cfml +blogPostDao.selectData( savedFilters=["publishedPosts"] ); +``` + +### Default filters (always applied) +```cfml +/** @defaultFilters publishedPosts,activeOnly */ +component { ... } + +// Bypass: +blogPostDao.selectData( ignoreDefaultFilters=["publishedPosts"] ); +``` + +## Select Fields & Formulas + +```cfml +// Simple fields +selectFields = [ "id", "title" ] + +// Cross-relationship (join traversal) +selectFields = [ "category.label as cat_label", "author.display_name as author_name" ] + +// Aggregate +selectFields = [ "Count(comments.id) as comment_count", "Max(comments.datecreated) as last_comment" ] + +// Formula property (defined on object) +property name="full_name" formula="Concat(${prefix}first_name, ' ', ${prefix}last_name)"; +property name="comment_count" formula="agg:count{ comments.id }"; +property name="latest_comment" formula="agg:max{ comments.datecreated }"; +``` + +## Versioning + +By default all objects are versioned. Version tables are named `_version_pobj_objectname`. + +```cfml +// Disable versioning entirely: +/** @versioned false */ +component { ... } + +// Disable version on insert only: +/** @versioned true @versionOnInsert false */ +component { ... } + +// Don't version a specific field's changes: +property name="_last_sync" ignoreChangesForVersioning=true; + +// M2M versioning (default: not versioned) +property name="tags" relationship="many-to-many" relatedTo="tag" versioned=true; + +// Get versions +var versions = presideObjectService.getRecordVersions( objectName="blog_post", id=postId ); +``` + +## Cloning + +```cfml +/** @cloneable true */ +component { ... } + +// Custom clone handler +/** @cloneHandler myCloner */ +component { ... } + +// /handlers/ObjectCloners/MyCloner.cfc +component { + function clone( objectName, recordId, data={} ) { + // return new record ID + } +} +``` + +## SelectData Views (v10.11.0+) + +Named queries for reuse across the codebase: + +```cfml +// /handlers/SelectDataViews.cfc +component { + private struct function activeBlogPosts( event, rc, prc ) { + return { + objectName = "blog_post" + , filter = { published=true } + , selectFields = [ "id", "title", "category" ] + , orderBy = "publish_date desc" + }; + } +} + +// Use: +var posts = presideObjectService.selectView( "activeBlogPosts" ); + +// Reference as relationship: +property name="active_posts" relationship="select-data-view" relatedTo="activeBlogPosts" relationshipKey="category"; +``` + +## Data Tenancy (v10.8.0+) + +Automatically scope data by a tenant (e.g. customer, site): + +```cfml +// Config.cfc +settings.tenancy.customer = { + object = "customer" + , defaultFk = "customer" +}; + +// Apply to object: +/** @tenant customer */ +component { ... } + +// Tenant ID provider: +// /handlers/tenancy/customer.cfc +component { + private string function getId( event, rc, prc ) { + return customerService.getCurrentCustomerId(); + } +} + +// Bypass tenancy: +dao.selectData( bypassTenants=["customer"] ); + +// Use alternative tenant: +dao.selectData( tenantIds={ customer=otherCustomerId } ); +``` + +## Label Renderers (v10.8.0+) + +Custom display for object pickers: + +```cfml +/** @labelRenderer session_category */ +component { ... } + +// /handlers/renderers/labels/session_category.cfc +component { + private array function _selectFields( event, rc, prc ) { + return [ "label", "colour" ]; + } + private string function _orderBy( event, rc, prc ) { return "label"; } + private string function _renderLabel( event, rc, prc ) { + return ' #HtmlEditFormat(arguments.label)#'; + } +} +``` + +## Data Exporters (v10.8.7+) + +```cfml +// Enable: +settings.features.dataexport.enabled = true; + +// On object: +/** + * @dataExportFields id,title,category,datecreated + * @dataExportExpandManytoOneFields true + */ +component { ... } + +// Custom exporter handler: /handlers/dataExporters/MyFormat.cfc +/** + * @exportFileExtension myext + * @exportMimeType application/x-myformat + */ +component { + private string function export( selectFields, fieldTitles, batchedRecordIterator, meta ) { + // return file path + } +} +``` + +## Object Extension (Merging) + +Objects with the same name across core/extensions/application are merged at runtime: + +```cfml +// /application/preside-objects/page.cfc +// Adds to the core page object: +component { + property name="custom_field" type="string" dbtype="varchar" maxlength="100"; + property name="old_field" deleted=true; // remove a property + property name="title" maxLength="50"; // override attribute +} +``` + +## ENUM Properties + +```cfml +// Config.cfc +settings.enum.statusType = [ "draft", "published", "archived" ]; + +// Object +property name="status" enum="statusType" type="string" dbtype="varchar" maxlength="20"; + +// i18n/enum/statusType.properties: +// draft.label=Draft +// published.label=Published +``` + +## Generated Fields + +```cfml +property name="slug" generator="slug" generateFrom="title" generate="insert"; +property name="unique_id" generator="UUID" generate="insert"; +property name="updated" generator="timestamp" generate="always"; +property name="seq" generator="nextint" generate="insert"; +property name="my_hash" generator="method:calculateHash" generate="always"; +``` + +## DB Sync Behaviour + +- New properties → new columns added +- Removed properties → column renamed to `_deprecated_fieldname` +- Tables never deleted when objects removed +- Adding required field to existing data → exception (use DB migration scripts) diff --git a/.context/email.md b/.context/email.md new file mode 100644 index 0000000000..e63b1901fe --- /dev/null +++ b/.context/email.md @@ -0,0 +1,349 @@ +# Email Templating System (v10.8.0+) + +## Architecture Overview + +``` +Email Layout → Visual wrapper (header, footer, branding) + └── Email Template → Content definition + parameter schema + └── Recipient Type → Who receives it and how to address them +``` + +--- + +## Email Layouts + +Layouts provide the HTML/text wrapper for all emails. + +```cfm + + + + + +
    + Logo +
    +
    + #args.body# +
    + + + +``` + +```cfm + + +#args.body# + +--- +#args.address# +Unsubscribe: #args.unsubscribeLink# + +``` + +Optional configuration form for layout settings: +```xml + +
    + +
    + + + +
    +
    +
    +``` + +```properties +# /i18n/email/layout/default.properties +title=Default Email Layout +description=Standard branded email layout + +field.header_logo.title=Header Logo +field.footer_colour.title=Footer Colour +field.address.title=Footer Address +``` + +--- + +## System Email Templates + +Three parts: Config.cfc declaration, i18n properties, handler. + +### 1. Config.cfc Declaration + +```cfml +settings.email.templates.bookingConfirmation = { + recipientType = "websiteUser" // or custom recipient type + , parameters = [ + { id="event_name", required=true } + , { id="booking_ref", required=true } + , { id="booking_summary", required=false } + , { id="edit_link", required=false } + ] +}; +``` + +### 2. i18n Properties + +```properties +# /i18n/email/template/bookingConfirmation.properties +title=Booking Confirmation +description=Sent to customers when they complete a booking + +param.event_name.title=Event Name +param.event_name.description=The name of the event booked + +param.booking_ref.title=Booking Reference +param.booking_summary.title=Booking Summary (HTML) +param.edit_link.title=Edit Booking Link +``` + +### 3. Handler + +```cfml +// /handlers/email/template/BookingConfirmation.cfc +component { + + property name="bookingService" inject="bookingService"; + + // Called at send time to resolve template parameters + private struct function prepareParameters( + required string bookingId // These names must match args passed to $sendEmail() + ) { + var booking = bookingService.getBookingDetails( arguments.bookingId ); + + return { + event_name = booking.event_name + , booking_ref = booking.reference + , booking_summary = { + html = renderView( view="/email/template/bookingConfirmation/_summaryHtml", args={ booking=booking } ) + , text = renderView( view="/email/template/bookingConfirmation/_summaryText", args={ booking=booking } ) + } + , edit_link = event.buildLink( linkTo="bookings.edit", queryString="id=#arguments.bookingId#" ) + }; + } + + // Shown in admin email template editor as preview + private struct function getPreviewParameters() { + return { + event_name = "Example Conference 2025" + , booking_ref = "BK-001234" + , booking_summary = { html="

    1 × Full Delegate Pass

    ", text="1 x Full Delegate Pass" } + , edit_link = "https://example.com/bookings/edit/?id=preview" + }; + } + + // Default subject (editor can override) + private string function defaultSubject() { + return "Your booking confirmation for ${event_name} (Ref: ${booking_ref})"; + } + + // Default HTML body + private string function defaultHtmlBody() { + return renderView( view="/email/template/bookingConfirmation/_defaultHtmlBody" ); + } + + // Default plain text body + private string function defaultTextBody() { + return renderView( view="/email/template/bookingConfirmation/_defaultTextBody" ); + } + + // Optional: recipient address override + private string function getToAddress( required string recipientId ) { + return bookingService.getPrimaryEmail( arguments.recipientId ); + } +} +``` + +### Default Body View + +```cfm + +

    Dear ${recipient:first_name},

    +

    Thank you for booking ${event_name}.

    +

    Your booking reference is: ${booking_ref}

    +${booking_summary} +

    Manage your booking

    +``` + +Variable substitution in email bodies uses `${param_name}` syntax. +Recipient variables use `${recipient:property_name}`. + +--- + +## Sending Emails + +```cfml +// From a service/handler (PresideSuperClass): +$sendEmail( + template = "bookingConfirmation" + , recipientId = websiteUserId // Resolved by recipient type + , args = { bookingId=bookingId } // Passed to prepareParameters() +); + +// With explicit to address (bypasses recipient type lookup): +$sendEmail( + template = "bookingConfirmation" + , to = "customer@example.com" + , args = { bookingId=bookingId } +); + +// Send to multiple: +$sendEmail( + template = "newsletter" + , to = [ "user1@example.com", "user2@example.com" ] + , args = {} +); + +// With extra params: +$sendEmail( + template = "bookingConfirmation" + , recipientId = userId + , args = { bookingId=bookingId } + , params = { additionalParam="value" } // Extra template variables +); +``` + +--- + +## Recipient Types + +Control how the email system resolves recipients. + +### Built-in Recipient Types +- `websiteUser` — uses `website_user` object +- `adminUser` — uses `security_user` object +- `anonymous` — no recipient tracking + +### Custom Recipient Type + +```cfml +// Config.cfc +settings.email.recipientTypes.eventDelegate = { + parameters = [ "first_name", "last_name", "email_address" ] + , filterObject = "event_delegate" + , gridFields = [ "first_name", "last_name", "email_address" ] + , recipientIdLogProperty = "event_delegate_recipient" +}; +``` + +```cfml +// /handlers/email/recipientType/EventDelegate.cfc +component { + + property name="delegateService" inject="eventDelegateService"; + + private struct function prepareParameters( required string recipientId ) { + var delegate = delegateService.getDelegate( arguments.recipientId ); + return { + first_name = delegate.first_name + , last_name = delegate.last_name + , email_address = delegate.email_address + }; + } + + private struct function getPreviewParameters() { + return { + first_name = "Jane" + , last_name = "Doe" + , email_address = "jane.doe@example.com" + }; + } + + private string function getToAddress( required string recipientId ) { + return delegateService.getDelegate( arguments.recipientId ).email_address; + } + + // Optional: filter records for admin "send to segment" UI + private struct function getFilterForBulkSend( event, rc, prc ) { + return { filter={ active=true } }; + } +} +``` + +--- + +## Email Service Providers + +Configure SMTP or third-party providers in the admin UI or Config.cfc: + +```cfml +// Config.cfc - register a custom provider +settings.email.serviceProviders.myProvider = { + configForm = "email.serviceprovider.myProvider" + , sendAction = "email.serviceprovider.myProvider.send" + , validateSettingsAction = "email.serviceprovider.myProvider.validateSettings" +}; +``` + +```cfml +// /handlers/email/serviceProvider/MyProvider.cfc +component { + + private boolean function send( struct sendArgs={}, struct settings={} ) { + var success = myProviderApi.sendMessage( + apiKey = settings.api_key + , to = sendArgs.to + , from = sendArgs.from + , subject = sendArgs.subject + , html = sendArgs.htmlBody + , text = sendArgs.textBody + ); + return success; + } + + private any function validateSettings( + required struct settings + , required any validationResult + ) { + if ( !Len(Trim(settings.api_key ?: "")) ) { + validationResult.addError( "api_key", "email.myprovider:validation.api_key.required" ); + } + return validationResult; + } +} +``` + +--- + +## Email Interception Points + +```cfml +component extends="coldbox.system.Interceptor" { + public void function configure() {} + + // Modify send arguments before sending + public void function onPrepareEmailSendArguments( event, interceptData ) { + // interceptData.sendArgs = { to, from, subject, htmlBody, textBody, ... } + interceptData.sendArgs.subject = "[ENV] " & interceptData.sendArgs.subject; + } + + // Just before send + public void function preSendEmail( event, interceptData ) { + // interceptData.sendArgs, interceptData.settings + } + + // After send (and after log entry) + public void function postSendEmail( event, interceptData ) { + logService.logEmailSent( interceptData.sendArgs ); + } +} +``` + +--- + +## Email Queue (Async Sending) + +By default emails are sent synchronously. Enable queueing for async: + +```cfml +settings.features.emailQueue.enabled = true; +settings.features.emailQueueHeartBeat.enabled = true; // Auto-processes queue +``` diff --git a/.context/forms-validation.md b/.context/forms-validation.md new file mode 100644 index 0000000000..e2acd8678d --- /dev/null +++ b/.context/forms-validation.md @@ -0,0 +1,322 @@ +# Forms & Validation + +## Form XML Structure + +Forms live in `/forms/` as XML files. Structure: `form > tab > fieldset > field`. + +```xml + +
    + + + +
    + + + + +
    +
    +
    +``` + +## i18n Conventions with i18nBaseUri + +Given `i18nBaseUri="system-config.email:"`, the system auto-resolves: +- Tab title: `system-config.email:tab.{id}.title` +- Tab description: `system-config.email:tab.{id}.description` +- Tab icon: `system-config.email:tab.{id}.iconClass` +- Fieldset title: `system-config.email:fieldset.{id}.title` +- Field label: `system-config.email:field.{name}.title` +- Field placeholder: `system-config.email:field.{name}.placeholder` +- Field help: `system-config.email:field.{name}.help` + +For Preside Object forms, the default `i18nBaseUri` is `preside-objects.{objectname}:`. + +## Field Binding to Preside Objects + +Binding pulls all attributes (control type, validation, i18n) from the object's property: + +```xml + + + + + +``` + +## Form Inheritance & Merging + +```xml + +
    + + +
    + +
    +
    + + +
    + +
    +
    +
    + + +
    + + +
    +
    + +
    + + +``` + +**Auto-merging:** Forms at the same relative path in core → extension → application → site-template are automatically merged. No explicit `extends` needed. + +## Standard Form Controls + +| Control | Description | +|---------|-------------| +| `textinput` | Single-line text | +| `textarea` | Multi-line text | +| `richeditor` | CKEditor rich text | +| `password` | Password input | +| `emailInput` | Email address | +| `select` | Dropdown | +| `radio` | Radio buttons | +| `checkbox` | Single checkbox | +| `checkboxList` | Multiple checkboxes | +| `yesNoSwitch` | Toggle switch | +| `spinner` | Numeric spinner | +| `datePicker` | Date picker | +| `timePicker` | Time picker | +| `datetimepicker` | Date + time | +| `objectPicker` | Select related Preside Object records | +| `manyToManySelect` | Multi-select for M2M | +| `assetPicker` | Asset Manager picker | +| `siteTreePagePicker` | Site tree page picker | +| `linkPicker` | URL/page/asset link picker | +| `enumSelect` | Dropdown from `settings.enum.myType` | +| `hidden` | Hidden field | +| `readonly` | Read-only display | +| `simpleColourPicker` | Colour picker | +| `captcha` | CAPTCHA | + +## Custom Form Control + +Create as a viewlet at `formcontrols.{controlName}.{context}`: + +**View-based control** (`/views/formcontrols/myControl/index.cfm`): +```cfm + + inputName = args.name ?: ""; + defaultValue = args.defaultValue ?: ""; + value = HtmlEditFormat( event.getValue( name=inputName, defaultValue=defaultValue ) ); + + + + +``` + +**Handler-based control** (`/handlers/formcontrols/MyControl.cfc`): +```cfml +component { + private string function index( event, rc, prc, args={} ) { + args.options = myService.getOptions(); + return renderView( view="formcontrols/select/index", args=args ); + } +} +``` + +## Validation Framework + +### Auto-Validation from Field Attributes +- `required="true"` → required validator +- `minLength="5"` / `maxLength="100"` → length validators +- `minValue="0"` / `maxValue="100"` → numeric range validators +- `type="date"` → date format validator +- `uniqueindexes` on object property → unique index validator (server-side only) + +### Explicit Validation Rules +```xml + + + + + + + + + + + + + +``` + +### Common Validators +`required`, `minLength`, `maxLength`, `rangeLength`, `min`, `max`, `range`, `email`, `sameAs`, `pattern`, `presideObjectUniqueIndex` + +### Custom Validator +```cfml +/** + * @validationProvider + */ +component { + /** + * @validator + * @validatorMessage myapp:validation.myvalidator.message + */ + public boolean function myValidator( + required string fieldName + , required any value + , required struct data + , required string someParam + ) { + return !Len(Trim(arguments.value)) || myCheck(arguments.value, arguments.someParam); + } + + // Optional: client-side JS + public string function myValidator_js() { + return "function(value, elem, params){ return !value.length || myJsCheck(value, params.someParam); }"; + } +} +``` + +Register in `Config.cfc`: +```cfml +settings.interceptors.append({ class="app.validators.MyValidator" }); +``` + +### Using Validation in Handlers +```cfml +function savePost( event, rc, prc ) { + var formName = "preside-objects.blog_post.admin.edit"; + var formData = event.getCollectionForForm( formName ); + var validationResult = validateForm( formName, formData ); + + if ( !validationResult.validated() ) { + setNextEvent( + url = event.buildAdminLink( linkTo="blog.editPost", queryString="id=#rc.id#" ) + , persistStruct = { validationResult=validationResult, formData=formData } + ); + } + // ... save data +} +``` + +### Rendering a Form +```cfm +
    + #renderForm( + formName = "my.form.name" + , context = "admin" + , formId = "my-form" + , validationResult = rc.validationResult ?: "" + , savedData = rc.formData ?: {} + )# + +
    +``` + +## Form File Naming Conventions + +| Purpose | Path | +|---------|------| +| Object add form | `/forms/preside-objects/{objectName}/admin.add.xml` | +| Object edit form | `/forms/preside-objects/{objectName}/admin.edit.xml` | +| Object default form (v10.9+) | `/forms/preside-objects/{objectName}.xml` | +| Page type form | `/forms/page-types/{pageTypeName}.xml` | +| Widget config form | `/forms/widgets/{widgetName}.xml` | +| System config form | `/forms/system-config/{categoryName}.xml` | +| Email layout config | `/forms/email/layout/{layoutId}.xml` | +| Translation form | `/forms/preside-objects/_translation_{objectName}/admin.edit.xml` | + +## Auto-Generated Forms + +If no form file exists for an object, Preside auto-generates one from the object's properties. This is usually sufficient for simple objects managed via Data Manager. + +## Programmatic Form Creation + +```cfml +var newFormName = formsService.createForm( function( formDefinition ) { + formDefinition.addField( + tab = "default" + , fieldset = "default" + , name = "title" + , control = "textinput" + , required = true + , maxLength = 200 + ); +}); + +// Based on existing form: +var newFormName = formsService.createForm( + basedOn = "preside-objects.blog_post.admin.edit" + , generator = function( formDefinition ) { + formDefinition.addField( + tab = "default" + , fieldset = "default" + , name = "extra_field" + , control = "textinput" + ); + } +); +``` + +## Getting Form Data from Request + +```cfml +// Get data for submitted form (auto-trims by default in admin) +var formData = event.getCollectionForForm( "my.form.name" ); + +// Disable auto-trim: +var formData = event.getCollectionForForm( formName="my.form.name", autoTrim=false ); +``` + +## richEditor Field Options + +```xml + + +``` + +Configure toolbars in `Config.cfc`: +```cfml +settings.ckeditor.toolbars.minimal = "Bold,Italic,Underline,-,Link,Unlink"; +settings.ckeditor.defaults.toolbar = "full"; +``` diff --git a/.context/handlers-views-viewlets.md b/.context/handlers-views-viewlets.md new file mode 100644 index 0000000000..1f9f276ce1 --- /dev/null +++ b/.context/handlers-views-viewlets.md @@ -0,0 +1,480 @@ +# Handlers, Views, Viewlets, Page Types & Widgets + +## Handler Conventions + +Preside follows ColdBox's handler conventions. Handlers live in `/handlers/`. + +```cfml +// /handlers/Blog.cfc +component { + + // Runs before every action in this handler + public void function preHandler( event, action, eventArguments ) { + // event = RequestContext + // rc = event.getCollection() — public (URL/form params) + // prc = event.getCollection(private=true) — view data + } + + public void function index( event, rc, prc ) { + prc.posts = getModel( "blogService" ).listPosts( page=rc.page ?: 1 ); + prc.totalPages = getModel( "blogService" ).getTotalPages(); + event.setView( "blog/index" ); + } + + public void function post( event, rc, prc ) { + var postId = rc.id ?: ""; + if ( !Len(Trim(postId)) ) { event.notFound(); } + prc.post = getModel( "blogService" ).getPost( postId ); + event.setView( "blog/post" ); + } +} +``` + +### Admin Handler +```cfml +component extends="preside.system.base.AdminHandler" { + + public void function preHandler( event, action, eventArguments ) { + super.preHandler( argumentCollection=arguments ); // REQUIRED — checks login, sets layout + event.addAdminBreadCrumb( title="Blog", link=event.buildAdminLink(linkTo="blog") ); + } + + public void function index( event, rc, prc ) { + prc.pageTitle = "Manage Blog"; + prc.pageIcon = "fa-pencil"; + event.setView( "admin/blog/index" ); + } + + // Action handlers must end with "Action" for CSRF protection + public void function savePostAction( event, rc, prc ) { ... } +} +``` + +### Key Event Object Methods + +```cfml +// Views & Rendering +event.setView( "path/to/view" ) // Set view (omit /views/ prefix) +event.setLayout( "layoutName" ) // Override layout +event.noLayout() // Render view without layout +event.renderData( type="json", data={} ) // Return JSON/XML/text directly + +// URLs +event.buildLink( page=pageId ) // Site tree page URL +event.buildLink( linkTo="handler.action" ) // Handler URL +event.buildLink( linkTo="handler.action", queryString="id=x" ) +event.buildAdminLink( linkTo="handler.action" ) // Admin URL +event.buildLink( assetId=assetId ) // Asset download URL +event.buildLink( assetId=assetId, derivative="thumb" ) + +// Request collection +rc.paramName // URL/form parameters +prc.paramName // Private collection (view data) + +// Site tree context +event.getCurrentPageId() +event.getPageProperty( "title" ) +event.getPageProperty( "page_type" ) +event.isCurrentPageActive() + +// Admin context +event.getAdminUserId() +event.isAdminUser() +event.isAdminRequest() + +// Errors +event.notFound() // 404 +event.accessDenied() // 403 +event.adminAccessDenied() // Admin 403 + +// Breadcrumbs +event.addAdminBreadCrumb( title="Title", link=url ) +event.getBreadCrumbs() + +// Caching +event.cachePage() // Is page being cached? (boolean) +event.cachePage( false ) // Disable page caching +event.setPageCacheTimeout( 3600 ) + +// Announcements +announceInterception( "myEvent", { key=value } ) +``` + +--- + +## Views + +Views are CFM files in `/views/`. Accessed as `handler/action.cfm`. + +```cfm + + + + + +
    + + + +
    +
    +``` + +### Preside Object Views (Data-Driven Views) + +Bypass handlers entirely — pass data directly to a view: + +```cfm +#renderView( + view = "blog/postCard" + , presideObject = "blog_post" + , filter = { published=true } + , orderBy = "publish_date desc" + , maxRows = 5 +)# +``` + +View declares what fields it needs via `cf_presideparam`: +```cfm + + + + + + + +
    +

    #args.title#

    +

    #args.teaser#

    + By #args.author# | #args.comment_count# comments +
    +
    +``` + +--- + +## Viewlets + +Viewlets are self-contained reusable components — a private handler action + a view. + +### Creating a Viewlet + +**Handler** (`/handlers/MyHandler.cfc`): +```cfml +component { + private string function recentPosts( event, rc, prc, args={} ) { + args.posts = getModel("blogService").getRecent( + limit = args.limit ?: 5 + , category = args.category ?: "" + ); + return renderView( view="/viewlets/recentPosts", args=args ); + } +} +``` + +**View** (`/views/viewlets/recentPosts.cfm`): +```cfm + + + + +``` + +**Usage** (from any view or handler): +```cfm +#renderViewlet( event="myHandler.recentPosts", args={ limit=3, category="news" } )# +``` + +### View-Only Viewlet + +If no handler exists, Preside looks for the view directly. Place at `/views/{event/path}/index.cfm`. + +### renderViewlet() Options + +```cfml +renderViewlet( + event = "handler.action" // Required + , args = { key=value } // Passed to handler + view + , cache = false // Cache the output + , cacheTimeout = 3600 // Cache TTL in seconds + , cacheKey = "my-unique-key" // Custom cache key + , delayed = false // Render after page cache fetch +) +``` + +--- + +## Page Types + +Page types define custom field groups for site tree pages. + +### Structure + +``` +/preside-objects/page-types/event.cfc # Data object +/handlers/page-types/event.cfc # Optional: handler with logic +/views/page-types/event/index.cfm # Default layout +/views/page-types/event/featured.cfm # Alternative layout +/forms/page-types/event.xml # Edit form (optional) +/i18n/page-types/event.properties # Labels +``` + +### Data Object + +```cfml +// /preside-objects/page-types/event.cfc +/** + * @allowedParentPageTypes * + * @allowedChildPageTypes none + * @showInSiteTree true + */ +component { + // 'page' FK is auto-added (many-to-one to page object) + property name="start_date" type="date" dbtype="date" required=true; + property name="end_date" type="date" dbtype="date" required=true; + property name="location" type="string" dbtype="varchar" maxLength=200; + property name="capacity" type="numeric" dbtype="int"; +} +``` + +### View (View-Only Mode) +```cfm + + + + + + + +
    +

    #args.title#

    +

    #DateFormat(args.start_date)# – #DateFormat(args.end_date)#

    +

    #HtmlEditFormat(args.location)#

    +
    +
    +``` + +### Handler (With Logic) +```cfml +// /handlers/page-types/event.cfc +component { + private string function index( event, rc, prc, args={} ) { + args.relatedEvents = getModel("eventService").getRelated( + eventId = event.getCurrentPageId() + ); + return renderView( view="/page-types/event/index", args=args ); + } +} +``` + +### i18n Properties +```properties +# /i18n/page-types/event.properties +name=Event Page +description=A page type for events +iconclass=fa-calendar + +layout.index=Default +layout.featured=Featured + +field.start_date.title=Start Date +field.end_date.title=End Date +``` + +--- + +## Widgets + +Widgets are user-placeable viewlets inserted in rich text editor fields. + +### Structure + +``` +/forms/widgets/promoBox.xml # Config form (editable by content editors) +/i18n/widgets/promoBox.properties # Labels +/handlers/widgets/PromoBox.cfc # Logic (index + optional placeholder) +/views/widgets/promoBox/index.cfm # Rendered output +``` + +### Handler +```cfml +// /handlers/widgets/PromoBox.cfc +component { + + /** @cacheable false */ + private string function index( event, rc, prc, args={} ) { + // args contains editor-configured values + return renderView( view="/widgets/promoBox/index", args=args ); + } + + // Optional: customise placeholder shown in the editor + private string function placeholder( event, rc, prc, args={} ) { + return "Promo Box: " & HtmlEditFormat( args.title ?: "" ); + } +} +``` + +### Config Form +```xml + +
    + +
    + + + + + +
    +
    +
    +``` + +### i18n Properties +```properties +# /i18n/widgets/promoBox.properties +title=Promo Box +description=A promotional content block +iconclass=fa-megaphone +placeholder=Promo Box: {1} + +field.title.label=Heading +field.linkUrl.label=Link URL +field.buttonText.label=Button text +field.image.label=Image +``` + +### Widget Feature Flags & Categories +```cfml +// Config.cfc +settings.features.myFeature = { + enabled = true, + widgets = [ "promoBox", "heroSlider" ] // Widgets only active with this feature +}; +``` + +Filter widget availability in a richeditor field: +```xml + +``` + +--- + +## Routing + +### Built-in Routes +| Pattern | Destination | +|---------|-------------| +| `/about-us/team/` | Site tree page (via slug matching) | +| `/admin/...` | Admin handlers via `admin.{handler}.{action}` | +| `/asset/{id}/` | Asset download | +| `/asset/{id}/{derivative}/` | Asset derivative | + +### Custom Route Handler +```cfml +// /handlers/routeHandlers/ProfileRouteHandler.cfc +component implements="preside.system.routeHandlers.iRouteHandler" { + + // Match incoming URL + public boolean function match( required string path, required any event ) { + return ReFindNoCase( "^/profile/", arguments.path ); + } + + // Translate URL to ColdBox event + public void function translate( required string path, required any event ) { + var action = ReReplace( arguments.path, "^/profile/", "" ); + action = ListChangeDelims( action, ".", "/" ); + event.setValue( "event", "profile." & action ); + } + + // For buildLink() to use this handler + public boolean function reverseMatch( required struct buildArgs, required any event ) { + return (buildArgs.linkTo ?: "") contains "profile."; + } + + public string function build( required struct buildArgs, required any event ) { + return "/profile/" & ListChangeDelims( ListRest(buildArgs.linkTo,"."), "/", "." ) & "/"; + } +} +``` + +Register in `/application/config/Routes.cfm`: +```cfml +addRouteHandler( getModel("ProfileRouteHandler") ); +``` + +--- + +## Site Tree Navigation Viewlets + +```cfm + +#renderViewlet( event="core.navigation.mainNavigation", args={ depth=2 } )# + + +#renderViewlet( event="core.navigation.subNavigation", args={ startLevel=2, depth=3 } )# + + +#renderViewlet( event="core.navigation.breadCrumbs" )# +``` + +Add breadcrumb from a handler: +```cfml +event.addBreadCrumb( title="My Section", link=event.buildLink(linkTo="section.index") ); +``` + +Intercept navigation to modify menu items: +```cfml +public void function onGetMainNavigationMenuItems( event, interceptData ) { + // interceptData.menuItems is an array of menu item structs + // Add, remove, or modify items here +} +``` + +--- + +## Full Page Caching + +```cfml +// Config.cfc +settings.features.fullPageCaching.enabled = true; +settings.fullPageCaching.limitCacheData = true; +settings.fullPageCaching.limitCacheDataKeys.prc = [ + "_site", "presidePage", "currentLayout" +]; + +// In a handler/widget — disable caching for this request: +event.cachePage( false ); +event.setPageCacheTimeout( 3600 ); // Custom TTL + +// In a widget — mark as not cacheable: +/** @cacheable false */ +private string function index( event, rc, prc, args={} ) { ... } + +// Render a viewlet AFTER cache is served (for personalised content): +#renderViewlet( event="widgets.UserGreeting", args=args, delayed=true )# +``` + +--- + +## Draft Content + +```cfml +// Enable drafts on object: +/** + * @datamanagerAllowDrafts true + */ +component { ... } + +// Or via Data Manager customization handler: +// getDraftPreviewActionButtons(), getDraftVersionLabelOptions(), etc. +``` diff --git a/.context/i18n.md b/.context/i18n.md new file mode 100644 index 0000000000..3ab095a0bb --- /dev/null +++ b/.context/i18n.md @@ -0,0 +1,196 @@ +# Internationalisation (i18n) + +## Resource Bundle System + +Translations live in `.properties` files under `/i18n/`. Files follow Java `.properties` format. + +```properties +# /i18n/myapp.properties +page.title=My Application +welcome.message=Welcome, {1}! You have {2} messages. +nav.home=Home +nav.blog=Blog +``` + +## URI Format + +``` +bundle:key.path +``` +The bundle name maps to the file: `myapp` → `/i18n/myapp.properties`. + +System bundles: `cms`, `preside-objects`, `validation`, `roles`, `permissions`, etc. + +``` +cms:sitetree.title +preside-objects.blog_post:field.title.title +system-config.email:tab.smtp.title +validation:required.message +roles:administrator.title +permissions:blog.add.title +``` + +## translateResource() + +Available in **handlers**, **views**, and **services** (via PresideSuperClass): + +```cfml +// Basic translation +translateResource( "myapp:page.title" ) + +// With default fallback +translateResource( uri="myapp:page.title", defaultValue="My App" ) + +// With substitution {1}, {2}, etc. +translateResource( uri="myapp:welcome.message", data=[ userName, messageCount ] ) + +// In views/handlers (short form) +translateResource( "cms:sitetree.editpage.title" ) + +// In services (PresideSuperClass prefix) +$translateResource( "myapp:page.title" ) +$translateResource( uri="myapp:page.title", data=["value"] ) +``` + +## Bundle File Locations + +| Bundle Name | File Path | +|-------------|-----------| +| `cms` | `/i18n/cms.properties` | +| `myapp` | `/i18n/myapp.properties` | +| `preside-objects.blog_post` | `/i18n/preside-objects/blog_post.properties` | +| `page-types.event` | `/i18n/page-types/event.properties` | +| `widgets.promoBox` | `/i18n/widgets/promoBox.properties` | +| `system-config.email` | `/i18n/system-config/email.properties` | +| `email.template.booking` | `/i18n/email/template/booking.properties` | +| `email.layout.default` | `/i18n/email/layout/default.properties` | +| `roles` | `/i18n/roles.properties` | +| `permissions` | `/i18n/permissions.properties` | +| `validation` | `/i18n/validation.properties` | +| `formbuilder.item-types.textinput` | `/i18n/formbuilder/item-types/textinput.properties` | +| `dataExporters.CSV` | `/i18n/dataExporters/CSV.properties` | +| `auditlog.mytype` | `/i18n/auditlog/mytype.properties` | +| `notifications.myTopic` | `/i18n/notifications/myTopic.properties` | +| `rules.contexts` | `/i18n/rules/contexts.properties` | + +## Preside Object Properties File + +Standard keys for a Preside Object: + +```properties +# /i18n/preside-objects/blog_post.properties +title=Blog Posts +title.singular=Blog Post +description=Manage your blog content +iconclass=fa-pencil + +# Field labels (used in forms + admin grid headers) +field.title.title=Post Title +field.title.placeholder=Enter the post title... +field.title.help=The main heading of the post + +field.published.title=Published +field.published.listing.title=Pub? # Short heading for grid column +field.published.listing.help=Whether post is live + +# Fieldset labels +fieldset.main.title=Content +fieldset.main.description=Main post content + +# Tab labels +tab.main.title=Post Content +tab.seo.title=SEO & Metadata +``` + +## Forms i18nBaseUri Convention + +When a form has `i18nBaseUri="system-config.email:"`: + +| Element | Resolved URI | +|---------|--------------| +| Tab `id="smtp"` title | `system-config.email:tab.smtp.title` | +| Tab `id="smtp"` description | `system-config.email:tab.smtp.description` | +| Tab `id="smtp"` icon | `system-config.email:tab.smtp.iconClass` | +| Fieldset `id="connection"` title | `system-config.email:fieldset.connection.title` | +| Field `name="server"` label | `system-config.email:field.server.title` | +| Field `name="server"` placeholder | `system-config.email:field.server.placeholder` | +| Field `name="server"` help text | `system-config.email:field.server.help` | + +## Multilingual Content (v10.8.0+) + +Enable the feature and mark objects/properties as multilingual: + +```cfml +// Config.cfc +settings.features.multilingual.enabled = true; +``` + +```cfml +// /preside-objects/article.cfc +/** + * @multilingual true + */ +component { + property name="title" type="string" dbtype="varchar" maxlength="200" multilingual=true; + property name="body" type="string" dbtype="text" multilingual=true; + property name="sku" type="string" dbtype="varchar" maxlength="50"; // NOT multilingual +} +``` + +Set language per request: +```cfml +// /handlers/General.cfc +component extends="preside.system.handlers.General" { + function requestStart( event, rc, prc ) { + super.requestStart( argumentCollection=arguments ); + event.setLanguage( "cy_GB" ); // Welsh + } +} +``` + +Translation edit form location: +- `/forms/preside-objects/_translation_{objectName}/admin.edit.xml` +- `/forms/preside-objects/_translation_page/admin.edit.xml` +- `/forms/preside-objects/_translation_{pageTypeName}/admin.edit.xml` + +List available languages: +```cfml +property name="mlService" inject="multilingualPresideObjectService"; + +var langs = mlService.listLanguages(); // array of language structs +``` + +## i18n Service + +```cfml +property name="i18n" inject="coldbox:plugin:i18n"; + +// Get current locale +i18n.getFwLocale() // e.g. "en_US" +i18n.getFWLanguageCode() // e.g. "en" +i18n.getFWCountryCode() // e.g. "US" + +// Translate +i18n.getResource( bundle="myapp", resource="key.path", locale="cy_GB" ) + +// Check if URI is valid +i18n.isValidResourceUri( "myapp:some.key" ) +``` + +## Adding Languages in Admin + +Languages are managed via the Preside admin under System > Languages. Each language needs: +- Language code (e.g. `cy`) +- Country code (e.g. `GB`) +- Name + +Translation files use locale suffix: `/i18n/cms_cy_GB.properties` overrides `/i18n/cms.properties` for Welsh (Wales). + +## Debug Mode + +Enable i18n debug mode to highlight untranslated strings: +```cfml +settings.i18n.debugMode = true; // In local() config +``` + +Untranslated keys are highlighted in the UI for easy identification. diff --git a/.context/misc.md b/.context/misc.md new file mode 100644 index 0000000000..2550ff0420 --- /dev/null +++ b/.context/misc.md @@ -0,0 +1,545 @@ +# Miscellaneous: Auditing, Notifications, Sessions, Migrations, Health Checks, CSRF, XSS, Workflow + +## Auditing + +### Log Audit Events + +```cfml +// In a handler: +event.audit( + action = "blog_post_published" + , type = "blog" + , recordId = postId + , detail = { title=post.title, publishedBy=event.getAdminUserId() } +); + +// In a service (PresideSuperClass): +$audit( + action = "api_token_used" + , type = "api" + , recordId = tokenId + , detail = { endpoint=endpoint } +); +``` + +### Audit i18n + +```properties +# /i18n/auditlog/blog.properties +title=Blog Management +iconClass=fa-pencil + +blog_post_published.title=Published blog post +blog_post_published.message={1} published the blog post "{2}" +blog_post_published.iconClass=fa-check green + +blog_post_deleted.title=Deleted blog post +blog_post_deleted.message={1} deleted the blog post "{2}" +blog_post_deleted.iconClass=fa-trash red +``` + +`{1}` = linked admin user name (auto-resolved), `{2}` etc = from `detail` struct. + +### Custom Audit Renderer + +```cfml +// /handlers/renderers/auditLogEntry/Blog.cfc +component { + private string function datatable( event, rc, prc, args={} ) { + return "Post " & HtmlEditFormat( args.detail.title ?: "" ) & " was " & args.action; + } + + private string function full( event, rc, prc, args={} ) { + return renderView( view="/renderers/auditLogEntry/blog/full", args=args ); + } +} +``` + +--- + +## Notifications + +In-app and email notifications for admin users. + +### Register Topic + +```cfml +// Config.cfc +settings.notificationTopics.append( "bookingCompleted" ); +``` + +```properties +# /i18n/notifications/bookingCompleted.properties +title=Booking Completed +description=Raised when a customer completes a booking +iconClass=fa-calendar-check-o +``` + +### Raise a Notification + +```cfml +property name="notificationService" inject="notificationService"; + +notificationService.createNotification( + topic = "bookingCompleted" + , type = "INFO" // "INFO", "WARNING", or "ALERT" + , data = { bookingId=newBookingId } +); +``` + +### Notification Renderers + +```cfml +// /handlers/renderers/notifications/BookingCompleted.cfc +component { + + property name="bookingService" inject="bookingService"; + + // Brief listing view + private string function datatable( event, rc, prc, args={} ) { + var booking = bookingService.getBooking( args.data.bookingId ?: "" ); + return "Booking #HtmlEditFormat( booking.reference )# completed"; + } + + // Detailed view + private string function full( event, rc, prc, args={} ) { + args.booking = bookingService.getBooking( args.data.bookingId ?: "" ); + return renderView( view="/renderers/notifications/bookingCompleted/full", args=args ); + } + + // Email subject + private string function emailSubject( event, rc, prc, args={} ) { + return "New booking completed: " & (args.data.bookingId ?: ""); + } + + // Email body + private string function emailHtml( event, rc, prc, args={} ) { + args.booking = bookingService.getBooking( args.data.bookingId ?: "" ); + return renderView( view="/renderers/notifications/bookingCompleted/emailHtml", args=args ); + } + + private string function emailText( event, rc, prc, args={} ) { + var booking = bookingService.getBooking( args.data.bookingId ?: "" ); + return "Booking #booking.reference# completed."; + } +} +``` + +### Gritter Toast Notifications (Admin UI) + +```cfml +// In a handler after an action: +getPlugin("messageBox").info( "Record saved successfully!" ); +getPlugin("messageBox").error( "Failed to save record." ); +getPlugin("messageBox").warning( "Record saved with warnings." ); +``` + +Configure position in `Config.cfc`: +```cfml +settings.adminNotificationsPosition = "bottom-right"; // top-left, top-right, bottom-left, bottom-right +settings.adminNotificationsSticky = true; +``` + +--- + +## Session Management + +### Default (Lucee Sessions) +Standard CFML session management. Configured in `Application.cfc`: +```cfml +super.setupApplication( + id = "myapp" + , sessionTimeout = CreateTimeSpan( 0, 0, 40, 0 ) // 40 minutes +); +``` + +### Preside Session Management (DB-backed) +For stateless/containerised deployments: +```cfml +super.setupApplication( + id = "myapp" + , presideSessionManagement = true +); +``` + +### Stateless Requests +Requests matching these patterns bypass session handling: +```cfml +super.setupApplication( + id = "myapp" + , statelessUrlPatterns = [ "^https?://[^/]+/api/.*" ] + , statelessUserAgentPatterns = [ "CFSCHEDULE", "bot\b", "spider\b" ] +); +``` + +### Session Storage Plugin +```cfml +property name="sessionStorage" inject="coldbox:plugin:sessionStorage"; + +sessionStorage.setVar( "key", value ); +var val = sessionStorage.getVar( "key", defaultValue ); +sessionStorage.deleteVar( "key" ); +``` + +--- + +## Custom DB Migrations + +One-time data migration scripts that run on application startup. + +### Migration Handler + +```cfml +// /handlers/dbmigrations/2024-03-15_addDefaultBlogCategories.cfc +component { + + // Runs synchronously on startup (blocking) + private void function run( event, rc, prc ) { + var categoryDao = $getPresideObject( "blog_category" ); + + if ( !categoryDao.dataExists( filter={ slug="uncategorised" } ) ) { + categoryDao.insertData( data={ + label = "Uncategorised" + , slug = "uncategorised" + } ); + } + } + + // Optional: runs ~1 minute after startup (non-blocking) + private void function runAsync( event, rc, prc ) { + _slowDataMigration(); + } + + // Optional (v10.20.0+): Only run if condition is met + private boolean function isEnabled() { + return isFeatureEnabled( "blog" ); + } +} +``` + +**Naming convention:** `YYYY-MM-DD_descriptiveName.cfc` — migrations run in alphabetical order. +**Each migration must be idempotent** (safe to re-run: check before inserting, use upsert patterns). + +--- + +## System Alerts + +Admin alerts for configuration/system issues. + +### Alert Check Handler + +```cfml +// /handlers/admin/systemAlerts/CheckEmailConfig.cfc +component { + + private void function runCheck( required systemAlertCheck check ) { + var settings = $getPresideCategorySettings( "email" ); + + if ( !Len( Trim( settings.smtp_server ?: "" ) ) ) { + check.fail(); + check.setLevel( "critical" ); // "critical", "warning", or "advisory" + check.setData({ message="SMTP server is not configured" }); + } + } + + // Optional: render alert detail in admin UI + private string function render( event, rc, prc, args={} ) { + return renderView( view="/admin/systemAlerts/checkEmailConfig/render", args=args ); + } + + private boolean function runAtStartup() { return true; } + + private string function schedule() { return "0 0 */6 * * *"; } // Every 6 hours + + private array function watchSettingsCategories() { return [ "email" ]; } + + private string function defaultLevel() { return "warning"; } +} +``` + +```properties +# /i18n/systemAlerts/checkEmailConfig.properties +title=Email Configuration Check +``` + +### Trigger Alert Check Programmatically + +```cfml +// In services/handlers: +runSystemAlertCheck( type="CheckEmailConfig" ); +runSystemAlertCheck( type="CheckDataMappings", reference=recordId, async=true ); +``` + +### SystemAlertCheck Methods + +```cfml +check.fail() +check.pass() +check.setLevel( "critical" | "warning" | "advisory" ) +check.setData( { customData="value" } ) +check.passes() // boolean +check.fails() // boolean +check.getType() +check.getReference() +check.getTrigger() // "startup", "settings", "schedule", "code", "rerun" +``` + +--- + +## Health Checks + +Periodic checks of external service availability. + +### Configure + +```cfml +// Config.cfc +settings.healthcheckServices.ElasticSearch = { + interval = CreateTimeSpan( 0, 0, 0, 30 ) // Every 30 seconds +}; +settings.healthcheckServices.RabbitMQ = { + interval = CreateTimeSpan( 0, 0, 1, 0 ) // Every 1 minute +}; +``` + +### Handler + +```cfml +// /handlers/healthcheck/ElasticSearch.cfc +component { + property name="elasticSearchService" inject="elasticSearchService"; + + private boolean function check() { + try { + return elasticSearchService.ping(); + } catch ( any e ) { + return false; + } + } +} +``` + +### Check Status in Code + +```cfml +// In handlers/views: +if ( isUp("elasticSearch") ) { + prc.results = elasticSearchService.search( rc.q ); +} else { + prc.results = fallbackSearch( rc.q ); +} + +// In services (PresideSuperClass): +if ( $isDown("elasticSearch") ) { + return fallbackSearch( arguments.q ); +} +``` + +--- + +## CSRF Protection + +### Admin Protection (Automatic) +Admin action handlers (names ending in `Action`) are automatically CSRF-protected. No code needed. + +```cfml +// This is protected automatically: +public void function savePostAction( event, rc, prc ) { ... } +``` + +Configure: +```cfml +settings.features.adminCsrfProtection.enabled = true; // Default +settings.csrf.tokenExpiryInSeconds = 3600; // Default: 1200 (20min) +``` + +### Frontend CSRF Protection + +```cfm + +
    + + +
    +``` + +```cfml +// In action handler: +function saveDetails( event, rc, prc ) { + if ( !event.validateCsrfToken() ) { + setNextEvent( + url = editUrl + , persistStruct = { error="Invalid security token. Please try again." } + ); + } + // ... proceed with save +} +``` + +--- + +## XSS Protection (AntiSamy) + +Automatically sanitises HTML submitted through rich text fields. + +```cfml +// Config.cfc +settings.antiSamy.enabled = true; // Default: on +settings.antiSamy.policy = "preside"; // Recommended +settings.antiSamy.bypassForAdministrators = false; // Default: false + +// Available policies: +// "preside" (recommended, Preside-specific) +// "antisamy" (default AntiSamy) +// "tinymce" +// "ebay" +// "myspace" +// "slashdot" +``` + +--- + +## Workflow System (v10.29+) + +### DataManager Workflow + +Define workflows in YAML and attach to Data Manager objects: + +```yaml +# /workflows/datamanager/article_publishing.yml +version: 1.0.0 +workflow: + id: article_publishing + + initialActions: + - id: create + result: + activateSteps: [ "draft" ] + + steps: + - id: draft + actions: + - id: submit_for_review + result: + activateSteps: [ "in_review" ] + + - id: in_review + actions: + - id: approve + permission: + key: articles.publish + result: + activateSteps: [ "published" ] + appendState: + published: true + - id: reject + form: preside-objects.article.reject + result: + activateSteps: [ "draft" ] + + - id: published + - id: archived +``` + +```cfml +// Object with workflow: +/** + * @datamanagerEnabled true + * @datamanagerWorkflowEnabled true + * @datamanagerWorkflowDefaultFlow article_publishing + * @datamanagerGridFields title,datamanager_workflow_status,published,datecreated + */ +component { + property name="title" type="string" dbtype="varchar" maxlength="200"; + property name="published" type="boolean" dbtype="boolean" default=false control="none"; +} +``` + +```properties +# /i18n/datamanagerWorkflow/article_publishing.properties +title=Article Publishing Workflow + +step.draft.title=Draft +step.in_review.title=In Review +step.published.title=Published + +step.draft.action.submit_for_review.title=Submit for Review +step.draft.action.submit_for_review.iconClass=fa-paper-plane + +step.in_review.action.approve.title=Approve & Publish +step.in_review.action.approve.iconClass=fa-check +step.in_review.action.reject.title=Send Back for Revision +step.in_review.action.reject.iconClass=fa-times +``` + +### Workflow Action Handlers + +```cfml +// /handlers/admin/datamanager/article.cfc +component extends="preside.system.base.AdminHandler" { + + private function canApprove( event, rc, prc, args={}, wfInstance ) { + return hasCmsPermission( "articles.publish" ); + } + + private function preApprove( event, rc, prc, args={}, wfInstance ) { + // Called before approve action executes + var state = wfInstance.getState(); + wfInstance.appendState({ approvedBy=event.getAdminUserId() }); + } + + private function postApprove( event, rc, prc, args={}, wfInstance ) { + // Called after approve action completes + notificationService.createNotification( + topic = "articlePublished" + , type = "INFO" + , data = { articleId=args.recordId } + ); + } +} +``` + +### Webflow (Multi-Step Frontend Forms) + +```yaml +# /workflows/webflows/registration.yml +version: 1.0.0 +webflow: + id: registration + singleton: false # Separate instance per user session + steps: + - id: personal_details + - id: contact_details + - id: confirmation + finish: true +``` + +```cfm + +#renderWebflow( "registration" )# +``` + +```cfml +// /handlers/webflow/Registration.cfc +component { + + private string function personal_details( event, rc, prc, args={}, wfInstance ) { + return renderView( view="/webflow/registration/personalDetails", args=args ); + } + + private void function personal_detailsAction( + event, rc, prc, args={} + , wfInstance + , persistData // Struct that persists between steps + , validationResult // Add errors to prevent advancing + ) { + if ( !Len( Trim( rc.firstName ?: "" ) ) ) { + validationResult.addError( "firstName", "First name is required" ); + } + if ( !validationResult.validated() ) { return; } + + persistData.firstName = rc.firstName; + persistData.lastName = rc.lastName; + } +} +``` diff --git a/.context/permissions.md b/.context/permissions.md new file mode 100644 index 0000000000..74c109b206 --- /dev/null +++ b/.context/permissions.md @@ -0,0 +1,255 @@ +# Permissions: Admin CMS & Website Users + +## CMS Admin Permissions + +### Hierarchy: Permissions → Roles → Groups → Users + +### 1. Define Permissions & Roles in Config.cfc + +```cfml +public void function configure() { + super.configure(); + + // Flat permissions: + settings.adminPermissions.blog = [ "navigate", "add", "edit", "delete" ]; + + // Nested permissions (produces blog.posts.add, blog.posts.edit, etc.): + settings.adminPermissions.blog = { + posts = [ "navigate", "add", "edit", "delete" ] + , categories = [ "navigate", "add", "edit", "delete" ] + }; + + // Define a role (collection of permission keys, supports wildcards + negation): + settings.adminRoles.blogEditor = [ + "blog.*" // All blog permissions + , "!blog.*.delete" // Except delete + , "cms.navigate" // Plus navigate CMS + ]; + + // Extend an existing role: + settings.adminRoles.administrator = settings.adminRoles.administrator ?: []; + settings.adminRoles.administrator.append( "blog.*" ); +} +``` + +### 2. i18n + +```properties +# /i18n/permissions.properties +blog.navigate.title=Access Blog section +blog.navigate.description=Permission to access blog management + +blog.posts.add.title=Create Blog Posts +blog.posts.add.description=Ability to create new blog posts + +# /i18n/roles.properties +blogEditor.title=Blog Editor +blogEditor.description=Manage blog posts and categories (no delete) +blogEditor.group=content + +roleGroup.content.title=Content Management +``` + +### 3. Check Permissions + +**In handlers/views:** +```cfml +// Simple check +if ( !hasCmsPermission("blog.posts.add") ) { + event.adminAccessDenied(); +} + +// Contextual check (e.g. per-folder, per-record) +if ( !hasCmsPermission( + permissionKey = "assetManager.folders.upload" + , context = "assetmanagerfolders" + , contextKeys = [ currentFolderId ] +) ) { + event.adminAccessDenied(); +} +``` + +**In services (PresideSuperClass):** +```cfml +$hasAdminPermission( "blog.posts.add" ) +$hasAdminPermission( permissionKey="blog.posts.add", userId=someUserId ) +``` + +**In views (conditionally show UI):** +```cfm + + Add Post + +``` + +### 4. Contextual Permissions UI Viewlet + +Render a permission management form for a specific context: + +```cfm +#renderViewlet( event="admin.permissions.contextPermsForm", args={ + permissionKeys = [ "blog.posts.*", "!blog.posts.delete" ] + , context = "blogcategory" + , contextKey = categoryId + , saveAction = event.buildAdminLink(linkTo="blog.savePermsAction", queryString="id=#categoryId#") + , cancelAction = event.buildAdminLink(linkTo="blog.manage", queryString="id=#categoryId#") +} )# +``` + +### 5. Admin Permission Service + +```cfml +property name="adminPermissionService" inject="adminPermissionService"; + +// Check permission +adminPermissionService.hasPermission( permissionKey="blog.add", userId=userId ) + +// List roles for display +adminPermissionService.listRoles() // array of role structs +adminPermissionService.listRolesWithGroup() // grouped struct + +// User groups +adminPermissionService.listUserGroups( userId=userId ) +adminPermissionService.userHasAssignedRoles( userId=userId, roles=["blogEditor"] ) + +// Context permissions +adminPermissionService.getContextPermissions( + context = "blogcategory" + , contextKeys = [ categoryId ] + , permissionKeys = [ "blog.posts.*" ] +) + +adminPermissionService.syncContextPermissions( + context = "blogcategory" + , contextKey = categoryId + , permissionKey = "blog.posts.add" + , grantedToGroups = [ groupId1 ] + , deniedToGroups = [ groupId2 ] +) +``` + +### 6. System Users (Bypass All Checks) + +```cfml +// Config.cfc +settings.system_users = "sysadmin,developer"; +// Users with these login IDs bypass all permission checks +``` + +--- + +## Website User Permissions + +Separate from CMS permissions — governs access to frontend website resources. + +### 1. Define Website Permissions + +```cfml +// Config.cfc +settings.websitePermissions.comments = [ "add", "edit", "delete" ]; +settings.websitePermissions.documents = [ "download", "upload" ]; +// Core built-ins: pages.access, assets.access +``` + +```properties +# /i18n/permissions.properties +comments.add.title=Add Comments +comments.add.description=Ability to post comments + +documents.download.title=Download Documents +documents.download.description=Access to download protected documents +``` + +### 2. Website User Objects + +- **`website_user`** — Login, email, BCrypt-hashed password +- **`website_benefit`** — User groups (the singular is "benefit", plural is "benefits") +- **`website_applied_permission`** — Grants/denies for users/benefits + +### 3. Check Website Permissions + +**In handlers/views:** +```cfml +// Simple +if ( !hasWebsitePermission("comments.add") ) { + event.accessDenied(); +} + +// Contextual +if ( !hasWebsitePermission( + permissionKey = "comments.edit" + , context = "commentthread" + , contextKeys = [ threadId ] +) ) { + event.accessDenied(); +} +``` + +**In views:** +```cfm + + Add Comment + +``` + +**In services:** +```cfml +property name="websitePermService" inject="websitePermissionService"; + +websitePermService.hasPermission( + permissionKey = "documents.download" + , userId = websiteLoginService.getLoggedInUserId() +) +``` + +### 4. Website Login Service + +```cfml +property name="websiteLoginService" inject="websiteLoginService"; + +// Login +var success = websiteLoginService.login( + loginId = rc.email + , password = rc.password + , rememberLogin = IsTrue( rc.rememberMe ?: "" ) + , rememberExpiryInDays = 30 +); + +// Check status +websiteLoginService.isLoggedIn() // boolean +websiteLoginService.isAutoLoggedIn() // boolean (from "remember me") +websiteLoginService.isImpersonated() // boolean (admin impersonating) + +// Get user +websiteLoginService.getLoggedInUserId() // string +websiteLoginService.getLoggedInUserDetails() // query + +// Logout +websiteLoginService.logout(); +``` + +### 5. Password Policy + +```cfml +// Config.cfc +settings.passwordPolicies.website = { + minLength = 8 + , minUpperCase = 1 + , minNumbers = 1 + , minSymbols = 0 +}; +``` + +--- + +## Quick Reference: Admin vs Website Permissions + +| Aspect | Admin (CMS) | Website Users | +|--------|-------------|---------------| +| Checking in handler | `hasCmsPermission("key")` | `hasWebsitePermission("key")` | +| Login check | `$isAdminUserLoggedIn()` | `$isWebsiteUserLoggedIn()` | +| Access denied | `event.adminAccessDenied()` | `event.accessDenied()` | +| Service | `adminPermissionService` | `websitePermissionService` | +| Config key | `settings.adminPermissions` | `settings.websitePermissions` | +| Roles | `settings.adminRoles` | N/A (uses benefits/groups) | +| User groups | Admin User Groups | `website_benefit` object | diff --git a/.context/rest-api.md b/.context/rest-api.md new file mode 100644 index 0000000000..a66eb8f3b7 --- /dev/null +++ b/.context/rest-api.md @@ -0,0 +1,284 @@ +# REST API Framework + +## Configuration + +```cfml +// Config.cfc +settings.rest.path = "/api"; // Default — all REST APIs under /api/ + +// CORS per API: +settings.rest.apis[ "/my-api/v1" ] = { + corsEnabled = true + , corsAllowedOrigins = [ "*" ] + , corsAllowedHeaders = [ "Authorization", "Content-Type" ] +}; +``` + +--- + +## Resource Handlers + +REST resources live in `/handlers/rest-apis/{api-name}/{version}/`. + +### Basic Resource + +```cfml +// /handlers/rest-apis/my-api/v1/Events.cfc + +/** + * @restUri /events/,/events/{id}/ + */ +component { + + property name="eventDao" inject="presidecms:object:event"; + + // Maps to GET /api/my-api/v1/events/ and GET /api/my-api/v1/events/{id}/ + private void function get( string id="" ) { + if ( Len( Trim( arguments.id ) ) ) { + var record = eventDao.selectData( + selectFields = [ "id", "title", "start_date", "location" ] + , filter = { id=arguments.id } + ); + if ( !record.recordCount ) { + restResponse.setStatus( 404, "Not Found" ).noData(); + return; + } + restResponse.setData( QueryGetRow( record, 1 ) ).setStatus( 200 ); + } else { + var records = eventDao.selectData( + selectFields = [ "id", "title", "start_date" ] + , filter = { published=true } + , orderBy = "start_date asc" + ); + restResponse.setData( QueryToArray( records ) ).setStatus( 200 ); + } + } + + // Maps to POST /api/my-api/v1/events/ + private void function post() { + var data = deserializeJSON( getHttpRequestData().content ); + var newId = eventDao.insertData( data=data ); + + restResponse + .setData({ id=newId }) + .setStatus( 201, "Created" ) + .setHeader( "Location", "/api/my-api/v1/events/#newId#/" ); + } + + // Maps to PUT /api/my-api/v1/events/{id}/ + private void function put( required string id ) { + var data = deserializeJSON( getHttpRequestData().content ); + eventDao.updateData( data=data, filter={ id=arguments.id } ); + restResponse.setStatus( 200 ).noData(); + } + + // Maps to DELETE (method name doesn't match HTTP verb — use @restVerb) + /** + * @restVerb DELETE + */ + private void function deleteEvent( required string id ) { + eventDao.deleteData( filter={ id=arguments.id } ); + restResponse.setStatus( 200 ).noData(); + } +} +``` + +URL pattern: `/api/{api-name}/{version}/{restUri}` +→ `/api/my-api/v1/events/` or `/api/my-api/v1/events/abc123/` + +--- + +## restResponse Object + +```cfml +restResponse.setData( myStruct ) // Response body (auto-serialized to JSON) +restResponse.setData( queryToArray( query ) ) // Arrays work too +restResponse.noData() // No response body +restResponse.setStatus( 200, "OK" ) // HTTP status code + message +restResponse.setStatus( 404, "Not Found" ) +restResponse.setStatus( 422, "Unprocessable Entity" ) +restResponse.setHeader( "X-Custom-Header", "value" ) +restResponse.setMimeType( "application/json" ) // Default +restResponse.setRenderer( "myCustomRenderer" ) +restResponse.setError( + errorCode = "INVALID_INPUT" + , message = "The submitted data is invalid" + , detail = { field="title", issue="required" } +) +``` + +--- + +## restRequest Object + +```cfml +restRequest.getUser() // Authenticated user ID (set by auth provider) +restRequest.getApi() // API identifier (e.g. "/my-api/v1") +restRequest.finish() // Stop processing, send current response +``` + +--- + +## Authentication + +Auth providers live at `/handlers/rest/auth/{providerId}.cfc`. + +### Token-Based Auth Provider + +```cfml +// /handlers/rest/auth/token.cfc +component { + + property name="apiAuthService" inject="apiAuthService"; + + // Return the user ID if authenticated, or empty string + private string function authenticate() { + var headers = getHttpRequestData( false ).headers; + var authHeader = headers.Authorization ?: ""; + + if ( !authHeader.startsWith("Bearer ") ) { + return ""; + } + + var token = Mid( authHeader, 8, Len(authHeader) ); + var userId = apiAuthService.getUserByToken( token ); + + if ( !Len( userId ) ) { + restResponse.setStatus( 401, "Unauthorized" ); + restRequest.finish(); + } + + return userId; + } +} +``` + +### Configuring Auth Per API + +```cfml +// Config.cfc +settings.rest.apis[ "/my-api/v1" ] = { + authProvider = "token" // References /handlers/rest/auth/token.cfc +}; +``` + +### Accessing Authenticated User + +```cfml +private void function get( string id="" ) { + var currentUserId = restRequest.getUser(); + // Returns empty string if not authenticated +} +``` + +--- + +## Interception Points + +```cfml +component extends="coldbox.system.Interceptor" { + public void function configure() {} + + // At start of every REST request + public void function onRestRequest( event, interceptData ) { + // interceptData.restRequest, interceptData.restResponse + } + + // On unhandled exception + public void function onRestError( event, interceptData ) { + // interceptData.error, interceptData.restRequest, interceptData.restResponse + restResponse.setStatus( 500, "Server Error" ) + .setError( errorCode="INTERNAL_ERROR", message=interceptData.error.message ); + } + + // When no matching resource found + public void function onMissingRestResource( event, interceptData ) { + restResponse.setStatus( 404, "Not Found" ).noData(); + } + + // Before/after invoking the resource action + public void function preInvokeRestResource( event, interceptData ) {} + public void function postInvokeRestResource( event, interceptData ) {} +} +``` + +--- + +## ETag Caching + +GET and HEAD responses automatically support ETag-based caching: +- Response includes `ETag` header (hash of response data) +- If client sends `If-None-Match` header matching ETag → returns `304 Not Modified` + +No code needed; handled automatically by the framework. + +--- + +## URL Structure + +``` +/api/{api-name}/{version}/{restUri} + +Examples: +GET /api/my-api/v1/events/ +GET /api/my-api/v1/events/abc123/ +POST /api/my-api/v1/events/ +PUT /api/my-api/v1/events/abc123/ +DELETE /api/my-api/v1/events/abc123/ + +GET /api/my-api/v1/events/abc123/attendees/ +POST /api/my-api/v1/events/abc123/attendees/ +``` + +The `@restUri` annotation supports multiple patterns and path params: +```cfml +/** + * @restUri /events/,/events/{id}/,/events/{id}/attendees/ + */ +component { ... } +``` + +Path parameter names must match function argument names: +```cfml +private void function get( string id="", string attendeeId="" ) { ... } +``` + +--- + +## Error Handling + +```cfml +// Return validation errors: +private void function post() { + var data = deserializeJSON( getHttpRequestData().content ); + var vr = validateForm( "my.form", data ); + + if ( !vr.validated() ) { + restResponse.setStatus( 422, "Unprocessable Entity" ).setError( + errorCode = "VALIDATION_ERROR" + , message = "The submitted data failed validation" + , detail = vr.getErrors() + ); + return; + } + // ... create record +} + +// Not found: +if ( !record.recordCount ) { + restResponse.setStatus( 404, "Not Found" ).noData(); + return; +} + +// Unauthorized: +if ( !restRequest.getUser().len() ) { + restResponse.setStatus( 401, "Unauthorized" ).noData(); + restRequest.finish(); +} + +// Forbidden: +if ( !hasPermission( restRequest.getUser(), "events.delete" ) ) { + restResponse.setStatus( 403, "Forbidden" ).noData(); + return; +} +``` diff --git a/.context/rules-engine.md b/.context/rules-engine.md new file mode 100644 index 0000000000..6e2d07d36a --- /dev/null +++ b/.context/rules-engine.md @@ -0,0 +1,376 @@ +# Rules Engine: Conditions, Filters & Expressions + +## Concepts + +| Term | Description | +|------|-------------| +| **Expression** | A single evaluatable item — returns true/false | +| **Condition** | User-configured combination of expressions (AND/OR) for access/display logic | +| **Filter** | Like a condition but tied to a single Preside Object — produces a database filter | +| **Context** | The evaluation environment (webrequest, page, user, etc.) | +| **Field Type** | UI control for expression configuration parameters | + +--- + +## Contexts + +Contexts define what data is available when evaluating expressions. + +### Configuration + +```cfml +// Config.cfc +settings.rulesEngine.contexts.webrequest = { + subcontexts = [ "user", "page" ] // These contexts' payload is merged in +}; +settings.rulesEngine.contexts.page = { + object = "page" // Object whose records can be used in filter building +}; +settings.rulesEngine.contexts.user = { + object = "website_user" +}; +``` + +### i18n + +```properties +# /i18n/rules/contexts.properties +webrequest.title=Web request +webrequest.description=Conditions for a web page request +webrequest.iconClass=fa-globe + +page.title=Web page +page.description=Conditions for a site tree page +page.iconClass=fa-file-o + +user.title=Website user +user.description=Conditions about the current user +user.iconClass=fa-user +``` + +### Context Payload Handler + +Provides the data available to expressions during evaluation: + +```cfml +// /handlers/rules/contexts/User.cfc +component { + private struct function getPayload() { + return { + user = { + id = isWebsiteUserLoggedIn() ? getLoggedInWebsiteUserId() : "" + , email = isWebsiteUserLoggedIn() ? getLoggedInWebsiteUserDetails().email_address : "" + } + }; + } +} +``` + +--- + +## Expressions + +### Simple Boolean Expression + +```cfml +// /handlers/rules/expressions/UserIsLoggedIn.cfc +/** + * @expressionContexts webrequest + */ +component { + + // _is is a magic boolean field — true = "is", false = "is not" + private boolean function evaluateExpression( boolean _is=true ) { + return arguments._is == isWebsiteUserLoggedIn(); + } +} +``` + +```properties +# /i18n/rules/expressions/userIsLoggedIn.properties +label=User is logged in +text=User {_is} logged in +``` + +### Expression With Fields + +```cfml +// /handlers/rules/expressions/UserHasBookedEvent.cfc +/** + * @expressionContexts webrequest,user + */ +component { + + property name="bookingService" inject="bookingService"; + + /** + * @emsEvent.fieldType object + * @emsEvent.object event + * @emsEvent.multiple false + */ + private boolean function evaluateExpression( + required string emsEvent + , boolean _has = true + ) { + var userId = payload.user.id ?: ""; + + if ( !Len(userId) || !Len(arguments.emsEvent) ) { + return !arguments._has; + } + + var hasBooked = bookingService.userHasBooked( + userId = userId + , eventId = arguments.emsEvent + ); + return hasBooked == arguments._has; + } +} +``` + +```properties +# /i18n/rules/expressions/userHasBookedEvent.properties +label=User has booked an event +text=User {_has} booked {emsEvent} +``` + +### Expression With Operators + +```cfml +// /handlers/rules/expressions/UserBookingCount.cfc +/** + * @expressionContexts user + */ +component { + + property name="bookingService" inject="bookingService"; + property name="rulesEngineOperatorService" inject="rulesEngineOperatorService"; + + /** + * @count.fieldType number + */ + private boolean function evaluateExpression( + required numeric count + , string _numericOperator = "gt" + ) { + var bookingCount = bookingService.getUserBookingCount( payload.user.id ?: "" ); + return rulesEngineOperatorService.compareNumbers( + bookingCount + , arguments._numericOperator + , arguments.count + ); + } +} +``` + +--- + +## Magic Field Types + +Parameters with special names get special UI treatment automatically: + +| Parameter Name | Behaviour | +|----------------|-----------| +| `_is` | Boolean toggle: "is" / "is not" | +| `_has`, `_possesses`, `_did`, `_was`, `_are`, `_will`, `_ever`, `_all` | Boolean variants | +| `_stringOperator` | String comparison dropdown (equals, contains, startsWith, etc.) | +| `_numericOperator` | Numeric comparison dropdown (gt, gte, lt, lte, eq, neq) | +| `_dateOperator` | Date comparison dropdown | +| `_periodOperator` | Period comparison | +| `_time` | Date/time range picker (past or future) | +| `_pastTime` | Past time range picker | +| `_futureTime` | Future time range picker | + +--- + +## Filter Expressions (for DB filtering) + +Filter expressions produce database filters (SQL) rather than returning a boolean. + +```cfml +// /handlers/rules/expressions/UserHasBookedEventFilter.cfc +/** + * @expressionContexts user + */ +component { + + property name="bookingDao" inject="presidecms:object:booking"; + + /** + * @objects website_user + */ + private array function prepareFilters( + required string emsEvent + , boolean _has = true + , required string objectName + , string filterPrefix = "" + ) { + var paramName = "event_#CreateUUId()#"; + var subQueryAlias = "bookings_#CreateUUId()#"; + var filterParams = { "#paramName#" = { value=arguments.emsEvent, type="cf_sql_varchar" } }; + var filterSql = "#subQueryAlias#.booking_count #arguments._has ? '>' : '='# 0"; + + var subQuery = bookingDao.selectData( + getSqlAndParamsOnly = true + , selectFields = [ "Count(id) as booking_count", "website_user as id" ] + , groupBy = "website_user" + , filter = "event = :#paramName#" + , filterParams = filterParams + ); + + return [{ + filter = filterSql + , filterParams = filterParams + , extraJoins = [{ + type = "left" + , subQuery = subQuery.sql + , subQueryAlias = subQueryAlias + , subQueryColumn = "id" + , joinToTable = arguments.objectName + , joinToColumn = "id" + }] + }]; + } +} +``` + +--- + +## Using Conditions in Code + +### Evaluate a condition (true/false check) + +```cfml +property name="rulesEngineConditionService" inject="rulesEngineConditionService"; + +function shouldShowWidget( required string conditionId ) { + if ( !Len( Trim( arguments.conditionId ) ) ) { return true; } + + return rulesEngineConditionService.evaluateCondition( + conditionId = arguments.conditionId + , context = "webrequest" + ); +} +``` + +In a view: +```cfm + + + +``` + +### Use a filter (database filtering) + +```cfml +property name="rulesEngineFilterService" inject="rulesEngineFilterService"; + +function getFilteredUsers( required string filterId ) { + var extraFilters = []; + + if ( Len( Trim( arguments.filterId ) ) ) { + extraFilters.append( + rulesEngineFilterService.prepareFilter( + objectName = "website_user" + , filterId = arguments.filterId + ) + ); + } + + return userDao.selectData( + filter = { active=true } + , extraFilters = extraFilters + , orderBy = "display_name asc" + ); +} +``` + +--- + +## Auto-Generated Expressions + +Preside can auto-generate basic filter expressions from object properties: + +```cfml +// On the object: +/** + * @autoGenerateFilterExpressionsFor website_user.email + */ +component { + property name="email" autofilter=true; // Included in auto-generation + property name="notes" autofilter=false; // Excluded +} + +// On a many-to-many property: +property name="categories" relationship="many-to-many" relatedTo="category" + autoGenerateFilterExpressions=true; +``` + +Customize auto-generated expression labels: +```properties +# /i18n/preside-objects/blog_post.properties +field.categories.possesses.truthy=is tagged with +field.categories.possesses.falsey=is not tagged with +``` + +--- + +## Custom Field Types + +```cfml +// /handlers/rules/fieldtypes/MyPicker.cfc +component { + + private string function renderConfiguredField( string value="", struct config={} ) { + // Render the configured value as human-readable text + if ( !Len( Trim( arguments.value ) ) ) { + return translateResource( "rules.fieldtypes.MyPicker:not.set" ); + } + return getModel("myService").getLabelForId( arguments.value ); + } + + private string function renderConfigScreen( string value="", struct config={} ) { + // Render the configuration UI (form control) + return renderFormControl( + name = "value" + , type = "objectPicker" + , object = "my_object" + , label = translateResource( config.fieldLabel ?: "rules.fieldtypes.MyPicker:config.label" ) + , savedValue = arguments.value + , required = true + ); + } + + // Optional: transform value into data usable by evaluateExpression() + private any function prepareConfiguredFieldData( string value="", struct config={} ) { + return getModel("myService").getDataForId( arguments.value ); + } +} +``` + +Register custom field type: +```cfml +// Config.cfc +settings.rulesEngine.fieldTypes.myPicker = { + handler = "rules.fieldtypes.MyPicker" +}; +``` + +--- + +## Condition Picker in Forms + +To let editors pick conditions/filters in forms: + +```xml + + + + + +``` diff --git a/.context/services-di.md b/.context/services-di.md new file mode 100644 index 0000000000..c8ea67b91f --- /dev/null +++ b/.context/services-di.md @@ -0,0 +1,294 @@ +# Services, Dependency Injection & PresideSuperClass + +## Service Layer Conventions + +Services are CFC files in `/services/`. They're auto-discovered and registered as singletons by WireBox. + +### Basic Service +```cfml +// /services/BlogService.cfc +/** + * @presideService true + * @singleton true + */ +component { + + public any function init( + /** + * @blogPostDao.inject presidecms:object:blog_post + * @categoryDao.inject presidecms:object:blog_category + */ + required any blogPostDao, + required any categoryDao + ) { + _setBlogPostDao( arguments.blogPostDao ); + _setCategoryDao( arguments.categoryDao ); + return this; + } + + public query function listPosts( numeric page=1, numeric perPage=10 ) { + return _getBlogPostDao().selectData( + filter = { published=true } + , orderBy = "publish_date desc" + , startRow = ((arguments.page-1) * arguments.perPage) + 1 + , maxRows = arguments.perPage + ); + } + + // Private getters/setters by convention + private any function _getBlogPostDao() { return _blogPostDao; } + private void function _setBlogPostDao( required any dao ) { _blogPostDao = arguments.dao; } + private any function _getCategoryDao() { return _categoryDao; } + private void function _setCategoryDao( required any dao ) { _categoryDao = arguments.dao; } +} +``` + +### Injecting Services into Handlers/Other Services +```cfml +// Via property injection +property name="blogService" inject="blogService"; + +// Via getModel() in handlers +var service = getModel("blogService"); + +// Via getInstance() anywhere +var service = getInstance("blogService"); +``` + +--- + +## PresideSuperClass + +Services annotated with `@presideService` get injected with helper methods prefixed `$`. You don't call them on `this` — they're mixed in. + +### Declaring a Preside Service + +```cfml +/** + * @presideService + */ +component { + function init() { return this; } +} + +// Equivalent (attribute syntax): +component presideService { + function init() { return this; } +} +``` + +### Data Access Methods + +```cfml +// Get object DAO +var dao = $getPresideObject( "blog_post" ); +var records = dao.selectData( filter={ published=true } ); + +// Or get the service directly +var objService = $getPresideObjectService(); +var records = objService.selectData( objectName="blog_post", filter={ published=true } ); +``` + +### System Settings +```cfml +var apiKey = $getPresideSetting( category="myapp", setting="api_key", default="" ); +var allConfig = $getPresideCategorySettings( category="myapp" ); +``` + +### Authentication +```cfml +// Admin user +$isAdminUserLoggedIn() // boolean +$getAdminLoggedInUserId() // string ID +$getAdminLoggedInUserDetails() // query row +$hasAdminPermission( "perm.key" ) // boolean + +// Website user +$isWebsiteUserLoggedIn() // boolean +$getWebsiteLoginService().getLoggedInUserId() +``` + +### Feature Flags +```cfml +$isFeatureEnabled( "myFeature" ) // boolean +$isFeatureEnabled( "feat1 or (feat2 and feat3)" ) // boolean expression +``` + +### Email +```cfml +$sendEmail( + template = "bookingConfirmation" + , recipientId = userId + , args = { bookingId=bookingId } +); +``` + +### Audit Trail +```cfml +$audit( + action = "blog_post_published" + , type = "blog" + , recordId = postId + , detail = { title=post.title } +); +``` + +### Task Manager +```cfml +// Run a scheduled task immediately +$runTask( taskKey="rebuildSearchIndexes", args={ index="main" } ); + +// Create an ad-hoc background task +var taskId = createTask( + event = "myHandler.longRunningAction" + , args = { someArg=someValue } + , runNow = true +); +``` + +### i18n +```cfml +$translateResource( "myapp:some.key" ) +$translateResource( uri="myapp:some.key", data=[ "John", 5 ] ) +``` + +### Helper Functions (v10.11.0+) +```cfml +// Access all ColdBox helper UDFs via $helpers +$helpers.isTrue( someValue ) +$helpers.formatDateTime( myDate ) +``` + +### Getting Other Services +```cfml +$getColdbox() // ColdBox controller +$getAdminLoginService() +$getWebsiteLoginService() +$getAdminPermissionService() +$getWebsitePermissionService() +$getEmailService() +$getNotificationService() +$getTaskManagerService() +$getAuditService() +$getContentRendererService() +$getValidationEngine() +$getFeatureService() +$getErrorLogService() +$getI18n() +``` + +--- + +## WireBox Injection DSL Reference + +| DSL Syntax | What it Injects | +|------------|-----------------| +| `inject="myService"` | Service named `myService` | +| `inject="presidecms:object:blog_post"` | Preside Object DAO for `blog_post` | +| `inject="cachebox:MyCache"` | Named CacheBox cache | +| `inject="coldbox:setting:myKey"` | ColdBox setting value | +| `inject="coldbox:plugin:sessionStorage"` | ColdBox plugin | +| `inject="delayedInjector:myService"` | Lazy-loaded service (resolved on first use) | +| `inject="featureInjection:myFeat:MyService"` | Service only if feature enabled | + +**`delayedInjector`** is important for avoiding circular dependency issues. Use it when service A depends on service B which depends on service A. + +### Property Injection (alternative to constructor) +```cfml +component { + property name="blogPostDao" inject="presidecms:object:blog_post"; + property name="cache" inject="cachebox:BlogCache"; + + function init() { return this; } + + function getRecentPosts() { + return blogPostDao.selectData( + filter = { published=true } + , maxRows = 5 + , orderBy = "datecreated desc" + ); + } +} +``` + +### Feature-Dependent Service Injection +```cfml +component { + property name="searchEngine" inject="featureInjection:elasticSearch:ElasticSearchService"; + + function search( required string q ) { + if ( $isFeatureEnabled("elasticSearch") ) { + return searchEngine.search( arguments.q ); + } + return fallbackSearch( arguments.q ); + } +} +``` + +--- + +## Custom WireBox Config + +```cfml +// /application/config/Wirebox.cfc +component extends="preside.system.config.Wirebox" { + + public void function configure() { + super.configure(); + + // Map a service with constructor args + map("profileImageStorage") + .asSingleton() + .to("preside.system.services.fileStorage.FileSystemStorageProvider") + .initArg( name="rootDirectory", value=expandPath("/uploads/profiles") ) + .initArg( name="trashDirectory", value=expandPath("/uploads/.trash") ) + .initArg( name="rootUrl", value="" ); + } +} +``` + +--- + +## Service Pattern: Private Getters/Setters + +Preside follows a convention of private `_getXxx()` / `_setXxx()` methods for encapsulation: + +```cfml +// Instead of: +variables.myService = arguments.myService; +return variables.myService; + +// Use: +private any function _getMyService() { + return _myService; +} +private void function _setMyService( required any service ) { + _myService = arguments.service; +} +``` + +This is a strong codebase convention. Follow it when adding new services. + +--- + +## Transient vs Singleton + +By default all services are singletons. For transient (new instance per injection): + +```cfml +/** + * @presideService true + * @singleton false + */ +component { + function init() { return this; } +} +``` + +Inject transients via `getInstance()` at call time rather than property injection: +```cfml +function doWork() { + var worker = getInstance("transientWorker"); + worker.process( data ); +} +``` diff --git a/.context/task-manager.md b/.context/task-manager.md new file mode 100644 index 0000000000..e228a44a05 --- /dev/null +++ b/.context/task-manager.md @@ -0,0 +1,255 @@ +# Task Manager: Scheduled & Ad-hoc Background Tasks + +## Scheduled Tasks + +Define scheduled tasks as private functions in `/handlers/Tasks.cfc` (or `/handlers/ScheduledTasks.cfc`). + +```cfml +// /handlers/Tasks.cfc +component { + + property name="searchService" inject="searchService"; + + /** + * @displayName Rebuild search indexes + * @displayGroup search + * @schedule 0 *\/15 * * * * + * @priority 10 + * @exclusivityGroup search + * @timeout 300 + */ + private boolean function rebuildSearchIndexes( event, rc, prc, logger ) { + logger.info( "Starting rebuild..." ); + + try { + searchService.rebuildAll( logger=arguments.logger ); + logger.info( "Rebuild complete." ); + return true; + } catch( any e ) { + logger.error( "Rebuild failed: #e.message#" ); + return false; + } + } + + /** + * @displayName Clean up tmp files + * @displayGroup maintenance + * @schedule 0 0 2 * * * + */ + private boolean function cleanTmpFiles( event, rc, prc, logger ) { + logger.info( "Cleaning tmp files older than 24 hours" ); + fileService.cleanTmp( maxAgeInHours=24 ); + return true; + } +} +``` + +### 6-Point Cron Schedule Format + +``` +S M H DoM Mon DoW +0 0 * * * * = Every hour at :00 +0 */15 * * * * = Every 15 minutes +0 30 2 * * 2 = 2:30 AM every Tuesday +0 0 4 1 * * = 4 AM on the 1st of each month +``` + +Fields: Second (0-59), Minute (0-59), Hour (0-23), Day of Month (1-31), Month (1-12), Day of Week (1-7, 1=Monday) + +### Task Annotations + +| Annotation | Description | +|------------|-------------| +| `@displayName` | Human-readable name shown in admin UI | +| `@displayGroup` | Tab grouping in admin task manager UI | +| `@schedule` | 6-point cron expression | +| `@priority` | Order in exclusivity group (lower = higher priority) | +| `@exclusivityGroup` | Tasks in same group won't run concurrently | +| `@timeout` | Max execution time in seconds (informational only as of v10.10.0) | + +### Logger Methods + +The `logger` argument supports: +```cfml +logger.info( "Informational message" ) +logger.warn( "Warning message" ) +logger.error( "Error message" ) +logger.fatal( "Fatal message" ) +``` + +### Check for Interruption + +```cfml +private boolean function longRunningTask( event, rc, prc, logger ) { + do { + if ( $isInterrupted() ) { + logger.warn( "Task interrupted, stopping gracefully" ); + return false; + } + _processNextBatch(); + } while( _hasMoreBatches() ); + return true; +} +``` + +### Run a Scheduled Task Programmatically + +```cfml +// In services (PresideSuperClass): +$runTask( taskKey="rebuildSearchIndexes" ); +$runTask( taskKey="rebuildSearchIndexes", args={ index="products" } ); + +// Via service: +property name="taskManagerService" inject="taskManagerService"; +taskManagerService.runTask( "rebuildSearchIndexes" ); +``` + +--- + +## Ad-hoc Background Tasks (v10.9.0+) + +For long-running operations triggered by user actions (imports, exports, etc.). + +### Create a Task + +```cfml +// In any handler or service: +var taskId = createTask( + event = "admin.blog.exportPostsInBackground" // Handler event path + , args = { format="csv", filter={ published=true } } + , runNow = true +); + +// With delay: +createTask( + event = "cleanup.oldFiles" + , args = { maxAgeDays=7 } + , runIn = CreateTimeSpan( 0, 0, 5, 0 ) // Run in 5 minutes +); + +// With retry logic: +createTask( + event = "integration.syncToExternalApi" + , args = { recordIds=selectedIds } + , runNow = true + , retryInterval = [ + { tries=2, interval=CreateTimeSpan( 0, 0, 5, 0 ) } // 5 min × 2 + , { tries=2, interval=CreateTimeSpan( 0, 0, 30, 0 ) } // 30 min × 2 + , { tries=1, interval=CreateTimeSpan( 0, 1, 0, 0 ) } // 1 hour × 1 + ] +); +``` + +### Task Handler + +```cfml +// /handlers/admin/Blog.cfc +component extends="preside.system.base.AdminHandler" { + + // Handler that creates the task and redirects to progress UI + public void function exportPosts( event, rc, prc ) { + var taskId = createTask( + event = "admin.blog.exportPostsInBackground" + , args = { format=rc.format ?: "csv" } + , runNow = true + , adminOwner = event.getAdminUserId() + , title = "cms:blog.export.task.title" + , resultUrl = event.buildAdminLink( + linkTo = "blog.downloadExport" + , queryString = "taskId={taskId}" + ) + , returnUrl = event.buildAdminLink( linkTo="blog.index" ) + ); + + setNextEvent( url=event.buildAdminLink( + linkTo = "adhoctaskmanager.progress" + , queryString = "taskId=" & taskId + ) ); + } + + // The actual background work + private void function exportPostsInBackground( + event, rc, prc + , args = {} + , logger // For logging progress messages + , progress // For reporting % complete + ) { + var format = args.format ?: "csv"; + var posts = blogService.getAllForExport(); + var total = posts.recordCount; + var done = 0; + + var filePath = getTempFile( getTempDirectory(), "BlogExport" ); + var writer = csvService.createWriter( filePath ); + + for ( var post in posts ) { + if ( progress.isCancelled() ) { + writer.close(); + FileDelete( filePath ); + abort; + } + + writer.writeRow( [ post.title, post.author, post.datecreated ] ); + done++; + + if ( !(done mod 50) || done == total ) { + progress.setProgress( Int( (done/total) * 100 ) ); + logger.info( "Exported #done# of #total# posts" ); + } + } + + writer.close(); + + // Store result for download handler + progress.setResult({ filePath=filePath, format=format }); + } + + // Download handler reads task result + public void function downloadExport( event, rc, prc ) { + property name="adhocTaskManagerService" inject="adhocTaskManagerService"; + + var task = adhocTaskManagerService.getProgress( rc.taskId ?: "" ); + var filePath = task.result.filePath ?: ""; + + if ( !FileExists(filePath) ) { event.notFound(); } + + header name="Content-Disposition" value='attachment; filename="export.csv"'; + content reset=true file=filePath type="text/csv" deletefile=true; + adhocTaskManagerService.discardTask( rc.taskId ); + abort; + } +} +``` + +### Progress Object Methods + +```cfml +progress.setProgress( 50 ) // 0-100 integer +progress.isCancelled() // boolean — user hit Cancel +progress.setResult( { key=value } ) // Store arbitrary result data +``` + +### AdHocTaskManagerService Methods + +```cfml +property name="adhocTaskManagerService" inject="adhocTaskManagerService"; + +adhocTaskManagerService.getProgress( taskId ) // struct with progress, result, status +adhocTaskManagerService.discardTask( taskId ) // Clean up after completion +adhocTaskManagerService.cancelTask( taskId ) // Request cancellation +``` + +### createTask() Options + +| Option | Description | +|--------|-------------| +| `event` | Handler event path (required) | +| `args` | Struct passed to handler as `args` | +| `runNow` | Boolean — run immediately | +| `runIn` | TimeSpan — delay before running | +| `runAt` | DateTime — specific scheduled time | +| `retryInterval` | Array of `{tries, interval}` retry structs | +| `adminOwner` | Admin user ID for ownership tracking | +| `title` | i18n key or text for admin UI display | +| `resultUrl` | URL to redirect to after completion (`{taskId}` substituted) | +| `returnUrl` | URL for Cancel/Back button | diff --git a/.gitignore b/.gitignore index 670b7045b5..e28a1b0474 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ jmimemagic.log .twgit* .zanata-cache +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..207f640678 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# Preside CMS — LLM Agent Context + +Preside CMS is an open-source, enterprise-grade CMS built on **ColdBox MVC** and **CFML (Lucee)**. It adds a rich data layer, admin console, permissioning, multi-site support, and extensibility on top of ColdBox. + +- **Docs site**: https://docs.preside.org/ +- **Docs source**: `~/code/elf/Preside-Documentation/docs/` +- **System source**: `/home/dom/code/elf/Preside-CMS/system/` +- **Application code**: typically `/application/` relative to webroot + +## Topic Context Files + +Detailed reference for each topic lives in `.context/`: + +| File | Covers | +|------|--------| +| [architecture.md](.context/architecture.md) | Bootstrap, Config.cfc, extensions, feature flags, reload, DI | +| [data-objects.md](.context/data-objects.md) | Preside Objects ORM: properties, relationships, CRUD API, filters, versioning | +| [forms-validation.md](.context/forms-validation.md) | Forms XML, field controls, inheritance, validation framework | +| [admin-ui.md](.context/admin-ui.md) | Data Manager, admin applications, left-hand menu, system menu | +| [handlers-views-viewlets.md](.context/handlers-views-viewlets.md) | Handlers, views, viewlets, page types, widgets, routing | +| [services-di.md](.context/services-di.md) | Service layer, WireBox DI, PresideSuperClass | +| [permissions.md](.context/permissions.md) | CMS admin permissions, website user permissions | +| [i18n.md](.context/i18n.md) | Resource bundles, translateResource, multilingual content | +| [task-manager.md](.context/task-manager.md) | Scheduled tasks, ad-hoc background tasks, progress tracking | +| [email.md](.context/email.md) | Email layouts, system templates, recipient types, service providers | +| [rest-api.md](.context/rest-api.md) | REST resources, routing, auth, response handling | +| [asset-manager.md](.context/asset-manager.md) | Assets, derivatives, storage providers, transformations | +| [rules-engine.md](.context/rules-engine.md) | Conditions, filter expressions, contexts, field types | +| [caching.md](.context/caching.md) | CacheBox config, full-page caching, delayed viewlets | +| [misc.md](.context/misc.md) | Auditing, notifications, sessions, migrations, health checks, CSRF, XSS, workflow | + +## Key Architectural Concepts (Quick Reference) + +### Technology Stack +- **Runtime**: Lucee CFML (5.2.9+) +- **Framework**: ColdBox MVC + WireBox DI + CacheBox +- **ORM**: Custom Preside Objects (not Hibernate) +- **Language**: CFML / CFScript (Lucee dialect) + +### Application Layout +``` +/application + /config/Config.cfc # Extends preside.system.config.Config + /preside-objects/ # Data object CFCs + /handlers/ # ColdBox event handlers + /services/ # Business logic (WireBox singletons) + /views/ # CFM view templates + /forms/ # XML form definitions + /i18n/ # .properties resource bundles + /layouts/ # Page layout CFMs + /extensions/ # Third-party extensions + /extensions_app/ # App-local extensions +.env # Environment variables (PRESIDE_ prefix) +``` + +### CFML Syntax Note +Preside uses **Lucee CFML**. Code is written in CFScript (`.cfc` files) or tag-based (`.cfm` views). CFScript syntax: +```cfml +component { + function init() { return this; } + function myMethod( required string arg1, string arg2="" ) { + var localVar = "value"; + return localVar; + } +} +``` + +### The `$` Prefix Convention +Services marked `@presideService` get injected with helper methods prefixed `$`: +```cfml +$getPresideObject( "my_object" ) // get DAO +$getPresideSetting( "cat", "key" ) // system settings +$isFeatureEnabled( "featureName" ) // feature flags +$hasAdminPermission( "perm.key" ) // permissions +$audit( action="x", type="y" ) // audit trail +$sendEmail( template="x", args={} ) // send email +``` + +### Config Inheritance +``` +preside.system.config.Config ← base + └── /application/config/Config.cfc ← app overrides + └── /application/config/LocalConfig.cfc ← local dev +``` +Environment variables override via `.env` (prefix `PRESIDE_`) or `/application/config/.injectedConfiguration`. + +### Reload URLs +``` +/?fwreinit=true # Full reload +/?fwReinitCaches=true # Clear caches only +/?fwReinitDbSync=true # Sync DB + reload objects +/?fwReinitForms=true # Reload form definitions +/?fwReinitI18n=true # Reload i18n bundles +``` + +### WireBox Injection DSL +```cfml +property name="myService" inject="myService"; +property name="myObject" inject="presidecms:object:my_object"; +property name="cacheProv" inject="cachebox:MyCache"; +property name="setting" inject="coldbox:setting:myKey"; +property name="lazyService" inject="delayedInjector:someService"; +property name="featureService" inject="featureInjection:myFeature:MyService"; +``` + +### i18n URI Format +``` +bundle:key.path +# e.g.: +cms:sitetree.title +preside-objects.blog_post:field.title.title +system-config.email:tab.smtp.title +``` + +### Feature Flags +```cfml +// Define in Config.cfc +settings.features.myFeature = { enabled=true, dependsOn=["admin"] }; + +// Check anywhere +if ( isFeatureEnabled( "myFeature" ) ) { ... } // handlers/views +if ( $isFeatureEnabled( "myFeature" ) ) { ... } // services + +// Apply to objects/handlers/views +/** @feature myFeature */ +component { ... } +```