Skip to content

v1.0.0

Choose a tag to compare

@github-actions github-actions released this 19 Oct 15:12
d9c7f18
  • @lowdefy/modules-mongodb-plugins: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #127 b8a2ef6 Thanks @SamTolmay! - Breaking: the app_name module var is replaced by Lowdefy's _app operator. Apps now declare their identity once, as slug: on the root of lowdefy.yaml, and every module reads it directly with _build.app: slug.

    Migration for consumers:

    • Add slug: <your-app-slug> to the root of lowdefy.yaml if it isn't there already. It must be kebab-case (^[a-z][a-z0-9]*(-[a-z0-9]+)*$) — Lowdefy validates it, and referencing an undeclared slug fails the build.
    • Remove the app_name: var from every module entry. activities, companies, contacts, deals, notifications, user-account, user-admin and workflows no longer declare it.
    • events.display_key is now optional, defaulting to the app's own slug. Drop it unless the app deliberately renders another app's event display strings.
    • If a change_stamp override baked in an app-attribution literal (app_name: my-app), change the value to { _build.app: slug }. The stamp field keeps the name app_name.
    • Any shared config file that existed only to hold app_name (e.g. an app_config.yaml) can be deleted.

    No data migration is required — the slug produces the same string the app_name var did, and no stored field or key is renamed. created.app_name keeps its name on event and notification documents.

    Connection schema changes in @lowdefy/modules-mongodb-plugins: the WorkflowAPI connection property app_name is renamed to slug, and the EventsTimeline connection property app_name is renamed to display_key (it carries the events module's display_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys.

  • #303 240e981 Thanks @SamTolmay! - Breaking: the profile name and avatar are derived on the server when a profile is written. contacts' create-contact and update-contact, and the shared write-profile fragment that user-account and user-admin use, all call one derivation (modules/shared/profile/derive-profile.js.njk). It sets profile.name, profile.avatar_color and profile.picture.

    • A payload's profile.picture is ignored. The write computes it, so renames update the avatar on every path. The contacts create and edit forms do not render an avatar preview.
    • profile.name is the non-empty given and family names joined by a space, so a person with only one name part keeps their name. Initials are the first letter of each part, or the first two letters of the only part.
    • A profile with no avatar_color is given one random palette entry, and it is stored so it stays stable. Picking a colour still wins, and people who already have a stored colour keep it. Because the rendered avatar is stored too, existing people are recoloured on their next write, not at upgrade. To recolour everyone, clear profile.avatar_color in a migration. See docs/shared/avatar-colors.md.
    • A profile with no name stores no picture, so the Avatar block shows its person icon.
    • Profile photos. The new AvatarUpload plugin block is a round avatar that is also the upload control. It crops, downscales and compresses the image in the browser to a data URI, which is stored inline. The shared avatar picker (onboarding and the profile modals) binds it to profile.photo, and an uploaded photo becomes the stored profile.picture. Removing the photo goes back to the initials avatar. update-profile rejects a photo that is not an image data URI or is over about 512 KB.
    • create-contact mints the contact _id on the server and ignores a payload _id. Read the new id from the response's contactId. A payload carrying a known _id with a new email could otherwise overwrite that contact.
    • Every payload value these writes put into an aggregation update is wrapped in $literal, so a value that begins with $ (a given_name of $email, or an email local part) is stored as written instead of being read as a field path.

    request_stages.write stages still run last, so a stage that overrides profile.name or profile.picture still wins.

  • #303 f7659a7 Thanks @SamTolmay! - A person's contact links to their auth user through user-contacts.user_id. A contact belongs to one organization, while the auth user is global, so the link lives on the contact and a person holds one contact per organization.

    • The caller's own contact is the one with { organization_id, user_id }: the organization from the tenant wall and the user from the session. The shared modules/shared/contact/resolve-own-contact.yaml fragment resolves it. If the person has no linked contact there, it claims the unlinked contact for their verified address and stamps user_id. A contact already linked to someone else is never claimed. After the claim, identity is by id, so an email change does not break the link.
    • modules/shared/contact/ensure-contact.yaml makes sure a contact exists and is safe to run on every sign-in. It sets user_id only when there is an auth user, so a contact minted for an invitee who has no login yet is unlinked until they first sign in.
    • Joins from users to user-contacts match through modules/shared/contact/contact-match-expr.yaml: user_id, or the address while the contact is still unclaimed.
    • The events timeline (GetEventsTimeline) finds an author's picture through the contact's user_id. Under policy: tenant only the caller's organization's contact is read. An author whose contact has no user_id yet shows initials.
    • App config that needs the caller's contact should resolve it with resolve-own-contact, or match { organization_id, user_id } in a join.

    New required index on user-contacts: { organization_id: 1, user_id: 1 }, unique, partial on user_id: { $exists: true }, alongside the partial-unique { organization_id: 1, lowercase_email: 1 }. Under pinned the single-field { user_id: 1 } and { lowercase_email: 1 } work too. See docs/user-account/reference/indexes.md. No backfill is needed: an existing contact is claimed on its person's next sign-in.

    Contacts are minted and claimed only for a verified email. Turn on requireEmailVerification so that an unverified signup holds no session.

  • #244 f6d14d8 Thanks @SamTolmay! - FileManager can take its upload policy from the page's own actions instead of a page request.

    When a page defines the new onUploadPolicy event, the block triggers it before each upload in place of the s3PostPolicyRequestId request, with _event set to { file, pasted }. The actions fetch the policy (for example from an API endpoint that checks permissions and picks the key on the server) and hand it back with the new setUploadPolicy method. If the event ends without a policy, the block holds the upload quietly, with no error state, so the page can ask the user and then send it (setUploadPolicy with the policy and the file's uid) or drop it (the new cancelUpload method with the uid, which from the event itself drops the upload instead of holding it). A file pasted from the clipboard carries pasted: true in the policy event and on onSave's file. Apps that set s3PostPolicyRequestId and define no onUploadPolicy behave as before.

    Each upload now keeps its own thumbnail, so two uploads started close together no longer swap thumbnails on onSave.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

  • #300 d2642ae Thanks @Gervwyk! - New NotificationSound block: an opt-in chirp for live notifications. It plays a short two-tone chirp synthesised in the browser (no audio file), at most once per batch of notifications across every open tab and reload. The sound is off until the viewer turns it on with the block's Sound switch (toggle: true); the setting is kept per app in the browser's localStorage, so it holds per device and other tabs follow a change at once. Without toggle the block draws nothing and only plays, through its play(ids) method.

  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.
  • #286 497fc1f Thanks @Gervwyk! - New ChatThread block. A two-party conversation thread: own messages on the right, the other side on the left with avatars, day pills, grouping of one author's messages within five minutes, linked URLs, and image or file attachments. It is its own scroll container and stays at the bottom while the reader is there. See docs/plugins/chat-thread.md.

  • #290 08abd4d Thanks @Gervwyk! - Pages that use the plugin connections now run in Lowdefy data-set journeys.

    • EventsTimeline, WorkflowAPI and ReportingData declare meta.dataSet: redirect, so a journey points them at its own database.
    • AiText declares meta.dataSet: external and keeps calling the AI Gateway.
    • These connections share one MongoDB client per URI but open the connection's databaseName on every request. Before, the first database opened on a URI was reused for every later request on it, whatever databaseName it named.
  • #294 b2e7950 Thanks @Gervwyk! - New MessageComposer block. One bordered message box: the text on top, the attached images and PDFs as small removable thumbnails with upload progress, and along the bottom an attach button, a short hint and the send button. Pasting an image or dropping a file onto the box attaches it, and Ctrl or Cmd with Enter sends. See docs/plugins/message-composer.md.

  • #286 e4dc983 Thanks @Gervwyk! - New SupportContext block, _support_context operator and error recorder. When the plugin's blocks or the operator load, a recorder on window starts keeping the tab's last 20 console.error calls, uncaught errors and unhandled rejections, in memory only. The SupportContext block renders nothing; its snapshot method sets its value to the page URL, user agent, viewport, screen, locale, time zone and those errors, and fires onSnapshot, for a support form to send with a ticket. The _support_context operator gives the same snapshot with the page id as a value, for an agent's shared state. See docs/plugins/support-context.md.

  • #286 0f463b9 Thanks @Gervwyk! - New SupportScreenshot block. A Take screenshot button that draws the visible page to a PNG from its DOM (no browser permission prompt), with every password input, every [data-support-mask] element and every maskSelectors match drawn as a solid box, and hideSelectors left out. The user can blur or crop the result before it is uploaded through an S3 presigned POST policy request; onUse fires with the uploaded file only after S3 stores it. With attachTo, a CSS selector of a file input on the page, Use hands the image to that input instead and the block uploads nothing. The page itself is left as it was. See docs/plugins/support-screenshot.md. Adds modern-screenshot as a dependency.

  • #286 cb004d1 Thanks @Gervwyk! - New SupportWebhook connection with a SupportWebhookVerify request. It checks that a webhook delivery from Pelican's support desk was signed with the app's webhook secret: any sha256=<hex> entry in Pelican-Signature must be the HMAC-SHA256 of "{Pelican-Timestamp}.{rawBody}", and the timestamp must be within five minutes of the server's clock. It is meant for an endpoint's webhook: { verify } gate, and fails verification rather than throwing on bad input. See docs/plugins/support-webhook.md.

Patch Changes

  • #253 5e4d09e Thanks @SamTolmay! - The ai-assistant module installs under auth.organizations.policy: tenant. The threads connection is walled like any MongoDB connection, so each organization keeps its own thread history. The AiText connection (thread titling) holds no data and declares itself non-scopable (tenant: false).

  • #266 97ba374 Thanks @SamTolmay! - A contact with one name part is labelled correctly in contact lists. The ContactSelector block's appendContact method named a newly created contact by joining the given and family names as written, so a contact with only one of them was added to the selection as Thandi undefined or undefined Nkosi. It now uses the trimmed non-empty parts joined by a space, and no name when both are empty, in which case the list item shows the email. The activities and companies contact lists, when a contact has no stored picture, now build the initials with the same rule as the shared profile derivation: the first letter of each part when both are set, otherwise the first two letters of the part that is set, and ? when neither is.

  • #286 2ed0457 Thanks @Gervwyk! - A refused upload is an error, not a saved file. FileManager treated the end of every upload post as success, so a post S3 refused (a file over the policy's size cap, an expired policy) or a network error still fired onSave for a file that never arrived. Only a 2xx answer is now a stored file; any other answer, a network error or an abort shows the upload as failed and fires no onSave.

  • #266 31984eb Thanks @SamTolmay! - Fix: report rows can no longer run as operators. compileReport now strips, at any depth, every key that starts with _ from the section rows it inlines into a report, except _id. Before this, a query field named __function, _state or __api ran as a live operator in the viewer's browser. _id is not an operator, so a $group result can still use it as a table column or KPI value. A contract that declares any other underscore key now renders that section as an Alert naming the available columns. Project the field under another name to show it.

  • @lowdefy/modules-mongodb-activities: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #127 b8a2ef6 Thanks @SamTolmay! - Breaking: the app_name module var is replaced by Lowdefy's _app operator. Apps now declare their identity once, as slug: on the root of lowdefy.yaml, and every module reads it directly with _build.app: slug.

    Migration for consumers:

    • Add slug: <your-app-slug> to the root of lowdefy.yaml if it isn't there already. It must be kebab-case (^[a-z][a-z0-9]*(-[a-z0-9]+)*$) — Lowdefy validates it, and referencing an undeclared slug fails the build.
    • Remove the app_name: var from every module entry. activities, companies, contacts, deals, notifications, user-account, user-admin and workflows no longer declare it.
    • events.display_key is now optional, defaulting to the app's own slug. Drop it unless the app deliberately renders another app's event display strings.
    • If a change_stamp override baked in an app-attribution literal (app_name: my-app), change the value to { _build.app: slug }. The stamp field keeps the name app_name.
    • Any shared config file that existed only to hold app_name (e.g. an app_config.yaml) can be deleted.

    No data migration is required — the slug produces the same string the app_name var did, and no stored field or key is renamed. created.app_name keeps its name on event and notification documents.

    Connection schema changes in @lowdefy/modules-mongodb-plugins: the WorkflowAPI connection property app_name is renamed to slug, and the EventsTimeline connection property app_name is renamed to display_key (it carries the events module's display_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.
  • #303 b4aad44 Thanks @SamTolmay! - Breaking: request_stages.filter_match takes plain MongoDB $match clauses instead of Atlas Search compound clauses. In contacts, companies and activities the structural filters moved out of $search into a standard $match, so the consumer filter hook is now standard query syntax and applies in both search modes. The var stays an array; only each element's syntax changes, and the clauses are composed via $and rather than shallow-merged, so a clause using $or is safe.

    These modules also gain a boolean atlas_search var (default true). Leave it true and text search runs through Atlas $search as before. Set it false — on contacts, companies, activities and deals — and text search falls back to a case-insensitive $regex $or over the same fields, which runs on any MongoDB: identical substring matching, but no relevance ranking and an unindexed collection scan, so it suits development and small collections rather than production at scale.

    With atlas_search: true the $search stage is unconditional — it runs on every list load, empty search box included, and only its contents vary. This is required by the tenant wall rather than chosen: $search must be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declares tenant: authored and carries the clause in compound.filter instead. That declaration is static per request while the search term is runtime, so a $search emitted only when a term happened to be present would be audited on the search path and refused on the browse path. The term clauses therefore sit in compound.must (a should alongside a filter clause ranks without narrowing, which would return every row for any term), and compound.filter always carries one clause — the audited organizationId equality under tenant, an exists match-all on _id under pinned, since Atlas refuses a compound whose clause lists are all empty. With atlas_search: false there is no $search at all and the wall scopes the leading $match mechanically.

    This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's default index to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two paths compound.filter needs — organizationId as a token (the authored tenant clause; a string equals requires that mapping specifically and no dynamic setting creates it) and _id (the pinned match-all). The structural filters are no longer evaluated by mongot, so their old token/date mappings are unused. storedSource now differs per collection: whole-document stored source is required on user-contacts and companies, and not required on activities and deals, whose requests read live documents so their post-write refetches do not show stale rows.

    Migration for consumers:

    • Rewrite any request_stages.filter_match clauses in MongoDB query syntax — { equals: { path: region, value: "x" } } becomes { region: "x" }, { range: { path: score, gte: 10 } } becomes { score: { $gte: 10 } }. The default [] is unaffected, so an app that never set the var has nothing to do.
    • For a non-Atlas deployment, set atlas_search: false on each searchable module entry (contacts, companies, activities, deals). Hold the value once in a shared app config file and _ref it from each entry, so the app has one source of truth for which mode it is in.
    • Create or update the default Atlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.md and docs/deals/index.md): whole-document storedSource: true on user-contacts and companies, no storedSource on activities and deals. Map organizationId as a token and _id in every one of them, whichever organizations policy the app runs — they are inert under pinned, and an index missing the organizationId mapping blanks every list page the moment a deployment flips to tenant, silently. deals needs a default index created from scratch: none existed before, so its search box has never worked, and the unconditional $search means the deal list now needs one to load at all. If companies.name_field is overridden, map that field in the companies index instead of name — otherwise Atlas $search silently returns no text matches on it while the regex fallback still works.
    • Before running the fallback at any size, add the regular mongod indexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.

    user-admin is not affected by the filter_match change — its clauses were already plain $match. Two non-breaking improvements land there: its members and invitations searches now treat regex metacharacters typed into the search box as literals (previously a ( made MongoDB reject the regex and .* matched every row), and both filter stages compose their clauses with $and so no two can collide on a key.

    The same escaping fix lands on deals' task Person Responsible selector, whose contact search had the same defect. All four search boxes now route through one shared clause builder, so escaping has a single home.

Patch Changes

  • #266 97ba374 Thanks @SamTolmay! - A contact with one name part is labelled correctly in contact lists. The ContactSelector block's appendContact method named a newly created contact by joining the given and family names as written, so a contact with only one of them was added to the selection as Thandi undefined or undefined Nkosi. It now uses the trimmed non-empty parts joined by a space, and no name when both are empty, in which case the list item shows the email. The activities and companies contact lists, when a contact has no stored picture, now build the initials with the same rule as the shared profile derivation: the first letter of each part when both are set, otherwise the first two letters of the part that is set, and ? when neither is.

  • #277 8ca77a4 Thanks @SamTolmay! - Pages whose title comes from a runtime operator no longer show "[object Object]" as the browser-tab title. The server writes the tab title without evaluating operators, so the layout page component now uses title only when it is a string at build time, and otherwise the page's type (for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build their type at build time, so their tab reads, for example, "Edit Company".

  • @lowdefy/modules-mongodb-ai-assistant: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #294 32f79c2 Thanks @Gervwyk! - Take a screenshot from the assistant's chat panel. With screenshot: true, the panel gets a Take screenshot button. It captures the page behind the panel with password inputs, [data-support-mask] elements and the screenshot_mask_selectors var's matches blanked, lets the user blur or crop it, and attaches it to the composer like any file, so the agent can send it with a support ticket. The chat uploads it through sender_attachments, so attachments must be on and the app's upload policy must accept an image. Not shown in the embedded shell.

  • #287 c6ea2f1 Thanks @Gervwyk! - File attachments in the assistant's chat, with stored file links signed again on open.

    sender_attachments turns on file attachments in the composer. It is passed through as the chat block's sender.attachments, naming the app's own upload and download requests. Null, the default, leaves attachments off.

    A file part keeps the link signed at upload, which stops working within days, so a thread reopened later would send the model dead links. file_links_endpoint names an app endpoint that signs new ones: when a thread is opened or resumed, the module collects the file parts that carry a storage key and calls it once with { files: [{ key, filename, mediaType }] }. It returns { files: [{ key, url }] }. Each part takes its key's new link, and a key returned with no url keeps the stored link. The keys come from the browser, so the endpoint must check each is one the caller may read before signing it.

  • #253 0934897 Thanks @SamTolmay! - New open-with-prompt component: an action list for a button anywhere on a page that mounts panel. It opens the panel on a new thread and sends its prompt var (a string, or a runtime operator resolved on click) into it, whether the panel is open on another thread, closed, or was never opened. The earlier thread stays in the list, untouched. Like the welcome prompts, the send does not run on_before_send.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

Patch Changes

  • #253 19cf65a Thanks @SamTolmay! - Threads save again when the tag var is left unset, and thread titling runs when title_context is left unset. Both vars default to null, and the save-thread and title-thread endpoints refused a null tag or context against their payload schemas, so an app that set neither never persisted a thread.

  • #253 5e4d09e Thanks @SamTolmay! - The ai-assistant module installs under auth.organizations.policy: tenant. The threads connection is walled like any MongoDB connection, so each organization keeps its own thread history. The AiText connection (thread titling) holds no data and declares itself non-scopable (tenant: false).

  • @lowdefy/modules-mongodb-ai-reporting: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #266 6617388 Thanks @SamTolmay! - Breaking: the report page renders under a dynamic blocks policy the app must list. On Lowdefy v7 a Dynamic block renders blocks that its endpoint built from data only under a dynamic blocks policy, through a ValidateDynamic step. Report blocks are compiled from a stored spec, so without one every saved report rendered the "Report not found" fallback. The module now exports that policy as the report-policy component, the report page's Dynamic block names it, and resolve-report checks the compiled blocks with ValidateDynamic before returning them.

    Lowdefy reads policies only from lowdefy.yaml, so consumers add:

    policies:
      dynamicBlocks:
        - _ref:
            module: ai-reporting
            component: report-policy

    The build fails without it, naming the missing policy. The policy keeps html: false and lists no outside origins, so a string in a report that contains HTML tag syntax or a URL is refused. A refused value inside a section, including a row value, renders that section as an Alert; one in the report title or description renders the fallback. A section that would take the report over the policy's 8 MB limits.bytes renders as an Alert beside its CSV export. Each refusal is in the server log. Report re-queries and downloads now name the report and section (report_id, section_id) instead of sending the stored query, and query-data and chart-data accept that form and load the query server-side.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.
  • @lowdefy/modules-mongodb-companies: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #127 b8a2ef6 Thanks @SamTolmay! - Breaking: the app_name module var is replaced by Lowdefy's _app operator. Apps now declare their identity once, as slug: on the root of lowdefy.yaml, and every module reads it directly with _build.app: slug.

    Migration for consumers:

    • Add slug: <your-app-slug> to the root of lowdefy.yaml if it isn't there already. It must be kebab-case (^[a-z][a-z0-9]*(-[a-z0-9]+)*$) — Lowdefy validates it, and referencing an undeclared slug fails the build.
    • Remove the app_name: var from every module entry. activities, companies, contacts, deals, notifications, user-account, user-admin and workflows no longer declare it.
    • events.display_key is now optional, defaulting to the app's own slug. Drop it unless the app deliberately renders another app's event display strings.
    • If a change_stamp override baked in an app-attribution literal (app_name: my-app), change the value to { _build.app: slug }. The stamp field keeps the name app_name.
    • Any shared config file that existed only to hold app_name (e.g. an app_config.yaml) can be deleted.

    No data migration is required — the slug produces the same string the app_name var did, and no stored field or key is renamed. created.app_name keeps its name on event and notification documents.

    Connection schema changes in @lowdefy/modules-mongodb-plugins: the WorkflowAPI connection property app_name is renamed to slug, and the EventsTimeline connection property app_name is renamed to display_key (it carries the events module's display_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.
  • #303 b4aad44 Thanks @SamTolmay! - Breaking: request_stages.filter_match takes plain MongoDB $match clauses instead of Atlas Search compound clauses. In contacts, companies and activities the structural filters moved out of $search into a standard $match, so the consumer filter hook is now standard query syntax and applies in both search modes. The var stays an array; only each element's syntax changes, and the clauses are composed via $and rather than shallow-merged, so a clause using $or is safe.

    These modules also gain a boolean atlas_search var (default true). Leave it true and text search runs through Atlas $search as before. Set it false — on contacts, companies, activities and deals — and text search falls back to a case-insensitive $regex $or over the same fields, which runs on any MongoDB: identical substring matching, but no relevance ranking and an unindexed collection scan, so it suits development and small collections rather than production at scale.

    With atlas_search: true the $search stage is unconditional — it runs on every list load, empty search box included, and only its contents vary. This is required by the tenant wall rather than chosen: $search must be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declares tenant: authored and carries the clause in compound.filter instead. That declaration is static per request while the search term is runtime, so a $search emitted only when a term happened to be present would be audited on the search path and refused on the browse path. The term clauses therefore sit in compound.must (a should alongside a filter clause ranks without narrowing, which would return every row for any term), and compound.filter always carries one clause — the audited organizationId equality under tenant, an exists match-all on _id under pinned, since Atlas refuses a compound whose clause lists are all empty. With atlas_search: false there is no $search at all and the wall scopes the leading $match mechanically.

    This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's default index to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two paths compound.filter needs — organizationId as a token (the authored tenant clause; a string equals requires that mapping specifically and no dynamic setting creates it) and _id (the pinned match-all). The structural filters are no longer evaluated by mongot, so their old token/date mappings are unused. storedSource now differs per collection: whole-document stored source is required on user-contacts and companies, and not required on activities and deals, whose requests read live documents so their post-write refetches do not show stale rows.

    Migration for consumers:

    • Rewrite any request_stages.filter_match clauses in MongoDB query syntax — { equals: { path: region, value: "x" } } becomes { region: "x" }, { range: { path: score, gte: 10 } } becomes { score: { $gte: 10 } }. The default [] is unaffected, so an app that never set the var has nothing to do.
    • For a non-Atlas deployment, set atlas_search: false on each searchable module entry (contacts, companies, activities, deals). Hold the value once in a shared app config file and _ref it from each entry, so the app has one source of truth for which mode it is in.
    • Create or update the default Atlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.md and docs/deals/index.md): whole-document storedSource: true on user-contacts and companies, no storedSource on activities and deals. Map organizationId as a token and _id in every one of them, whichever organizations policy the app runs — they are inert under pinned, and an index missing the organizationId mapping blanks every list page the moment a deployment flips to tenant, silently. deals needs a default index created from scratch: none existed before, so its search box has never worked, and the unconditional $search means the deal list now needs one to load at all. If companies.name_field is overridden, map that field in the companies index instead of name — otherwise Atlas $search silently returns no text matches on it while the regex fallback still works.
    • Before running the fallback at any size, add the regular mongod indexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.

    user-admin is not affected by the filter_match change — its clauses were already plain $match. Two non-breaking improvements land there: its members and invitations searches now treat regex metacharacters typed into the search box as literals (previously a ( made MongoDB reject the regex and .* matched every row), and both filter stages compose their clauses with $and so no two can collide on a key.

    The same escaping fix lands on deals' task Person Responsible selector, whose contact search had the same defect. All four search boxes now route through one shared clause builder, so escaping has a single home.

Patch Changes

  • #266 97ba374 Thanks @SamTolmay! - A contact with one name part is labelled correctly in contact lists. The ContactSelector block's appendContact method named a newly created contact by joining the given and family names as written, so a contact with only one of them was added to the selection as Thandi undefined or undefined Nkosi. It now uses the trimmed non-empty parts joined by a space, and no name when both are empty, in which case the list item shows the email. The activities and companies contact lists, when a contact has no stored picture, now build the initials with the same rule as the shared profile derivation: the first letter of each part when both are set, otherwise the first two letters of the part that is set, and ? when neither is.

  • #277 8ca77a4 Thanks @SamTolmay! - Pages whose title comes from a runtime operator no longer show "[object Object]" as the browser-tab title. The server writes the tab title without evaluating operators, so the layout page component now uses title only when it is a string at build time, and otherwise the page's type (for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build their type at build time, so their tab reads, for example, "Edit Company".

  • @lowdefy/modules-mongodb-contacts: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #127 b8a2ef6 Thanks @SamTolmay! - Breaking: the app_name module var is replaced by Lowdefy's _app operator. Apps now declare their identity once, as slug: on the root of lowdefy.yaml, and every module reads it directly with _build.app: slug.

    Migration for consumers:

    • Add slug: <your-app-slug> to the root of lowdefy.yaml if it isn't there already. It must be kebab-case (^[a-z][a-z0-9]*(-[a-z0-9]+)*$) — Lowdefy validates it, and referencing an undeclared slug fails the build.
    • Remove the app_name: var from every module entry. activities, companies, contacts, deals, notifications, user-account, user-admin and workflows no longer declare it.
    • events.display_key is now optional, defaulting to the app's own slug. Drop it unless the app deliberately renders another app's event display strings.
    • If a change_stamp override baked in an app-attribution literal (app_name: my-app), change the value to { _build.app: slug }. The stamp field keeps the name app_name.
    • Any shared config file that existed only to hold app_name (e.g. an app_config.yaml) can be deleted.

    No data migration is required — the slug produces the same string the app_name var did, and no stored field or key is renamed. created.app_name keeps its name on event and notification documents.

    Connection schema changes in @lowdefy/modules-mongodb-plugins: the WorkflowAPI connection property app_name is renamed to slug, and the EventsTimeline connection property app_name is renamed to display_key (it carries the events module's display_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys.

  • #303 240e981 Thanks @SamTolmay! - Breaking: the profile name and avatar are derived on the server when a profile is written. contacts' create-contact and update-contact, and the shared write-profile fragment that user-account and user-admin use, all call one derivation (modules/shared/profile/derive-profile.js.njk). It sets profile.name, profile.avatar_color and profile.picture.

    • A payload's profile.picture is ignored. The write computes it, so renames update the avatar on every path. The contacts create and edit forms do not render an avatar preview.
    • profile.name is the non-empty given and family names joined by a space, so a person with only one name part keeps their name. Initials are the first letter of each part, or the first two letters of the only part.
    • A profile with no avatar_color is given one random palette entry, and it is stored so it stays stable. Picking a colour still wins, and people who already have a stored colour keep it. Because the rendered avatar is stored too, existing people are recoloured on their next write, not at upgrade. To recolour everyone, clear profile.avatar_color in a migration. See docs/shared/avatar-colors.md.
    • A profile with no name stores no picture, so the Avatar block shows its person icon.
    • Profile photos. The new AvatarUpload plugin block is a round avatar that is also the upload control. It crops, downscales and compresses the image in the browser to a data URI, which is stored inline. The shared avatar picker (onboarding and the profile modals) binds it to profile.photo, and an uploaded photo becomes the stored profile.picture. Removing the photo goes back to the initials avatar. update-profile rejects a photo that is not an image data URI or is over about 512 KB.
    • create-contact mints the contact _id on the server and ignores a payload _id. Read the new id from the response's contactId. A payload carrying a known _id with a new email could otherwise overwrite that contact.
    • Every payload value these writes put into an aggregation update is wrapped in $literal, so a value that begins with $ (a given_name of $email, or an email local part) is stored as written instead of being read as a field path.

    request_stages.write stages still run last, so a stage that overrides profile.name or profile.picture still wins.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.
  • #303 b4aad44 Thanks @SamTolmay! - Breaking: request_stages.filter_match takes plain MongoDB $match clauses instead of Atlas Search compound clauses. In contacts, companies and activities the structural filters moved out of $search into a standard $match, so the consumer filter hook is now standard query syntax and applies in both search modes. The var stays an array; only each element's syntax changes, and the clauses are composed via $and rather than shallow-merged, so a clause using $or is safe.

    These modules also gain a boolean atlas_search var (default true). Leave it true and text search runs through Atlas $search as before. Set it false — on contacts, companies, activities and deals — and text search falls back to a case-insensitive $regex $or over the same fields, which runs on any MongoDB: identical substring matching, but no relevance ranking and an unindexed collection scan, so it suits development and small collections rather than production at scale.

    With atlas_search: true the $search stage is unconditional — it runs on every list load, empty search box included, and only its contents vary. This is required by the tenant wall rather than chosen: $search must be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declares tenant: authored and carries the clause in compound.filter instead. That declaration is static per request while the search term is runtime, so a $search emitted only when a term happened to be present would be audited on the search path and refused on the browse path. The term clauses therefore sit in compound.must (a should alongside a filter clause ranks without narrowing, which would return every row for any term), and compound.filter always carries one clause — the audited organizationId equality under tenant, an exists match-all on _id under pinned, since Atlas refuses a compound whose clause lists are all empty. With atlas_search: false there is no $search at all and the wall scopes the leading $match mechanically.

    This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's default index to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two paths compound.filter needs — organizationId as a token (the authored tenant clause; a string equals requires that mapping specifically and no dynamic setting creates it) and _id (the pinned match-all). The structural filters are no longer evaluated by mongot, so their old token/date mappings are unused. storedSource now differs per collection: whole-document stored source is required on user-contacts and companies, and not required on activities and deals, whose requests read live documents so their post-write refetches do not show stale rows.

    Migration for consumers:

    • Rewrite any request_stages.filter_match clauses in MongoDB query syntax — { equals: { path: region, value: "x" } } becomes { region: "x" }, { range: { path: score, gte: 10 } } becomes { score: { $gte: 10 } }. The default [] is unaffected, so an app that never set the var has nothing to do.
    • For a non-Atlas deployment, set atlas_search: false on each searchable module entry (contacts, companies, activities, deals). Hold the value once in a shared app config file and _ref it from each entry, so the app has one source of truth for which mode it is in.
    • Create or update the default Atlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.md and docs/deals/index.md): whole-document storedSource: true on user-contacts and companies, no storedSource on activities and deals. Map organizationId as a token and _id in every one of them, whichever organizations policy the app runs — they are inert under pinned, and an index missing the organizationId mapping blanks every list page the moment a deployment flips to tenant, silently. deals needs a default index created from scratch: none existed before, so its search box has never worked, and the unconditional $search means the deal list now needs one to load at all. If companies.name_field is overridden, map that field in the companies index instead of name — otherwise Atlas $search silently returns no text matches on it while the regex fallback still works.
    • Before running the fallback at any size, add the regular mongod indexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.

    user-admin is not affected by the filter_match change — its clauses were already plain $match. Two non-breaking improvements land there: its members and invitations searches now treat regex metacharacters typed into the search box as literals (previously a ( made MongoDB reject the regex and .* matched every row), and both filter stages compose their clauses with $and so no two can collide on a key.

    The same escaping fix lands on deals' task Person Responsible selector, whose contact search had the same defect. All four search boxes now route through one shared clause builder, so escaping has a single home.

Patch Changes

  • #277 8ca77a4 Thanks @SamTolmay! - Pages whose title comes from a runtime operator no longer show "[object Object]" as the browser-tab title. The server writes the tab title without evaluating operators, so the layout page component now uses title only when it is a string at build time, and otherwise the page's type (for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build their type at build time, so their tab reads, for example, "Edit Company".

  • @lowdefy/modules-mongodb-deals: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #127 b8a2ef6 Thanks @SamTolmay! - Breaking: the app_name module var is replaced by Lowdefy's _app operator. Apps now declare their identity once, as slug: on the root of lowdefy.yaml, and every module reads it directly with _build.app: slug.

    Migration for consumers:

    • Add slug: <your-app-slug> to the root of lowdefy.yaml if it isn't there already. It must be kebab-case (^[a-z][a-z0-9]*(-[a-z0-9]+)*$) — Lowdefy validates it, and referencing an undeclared slug fails the build.
    • Remove the app_name: var from every module entry. activities, companies, contacts, deals, notifications, user-account, user-admin and workflows no longer declare it.
    • events.display_key is now optional, defaulting to the app's own slug. Drop it unless the app deliberately renders another app's event display strings.
    • If a change_stamp override baked in an app-attribution literal (app_name: my-app), change the value to { _build.app: slug }. The stamp field keeps the name app_name.
    • Any shared config file that existed only to hold app_name (e.g. an app_config.yaml) can be deleted.

    No data migration is required — the slug produces the same string the app_name var did, and no stored field or key is renamed. created.app_name keeps its name on event and notification documents.

    Connection schema changes in @lowdefy/modules-mongodb-plugins: the WorkflowAPI connection property app_name is renamed to slug, and the EventsTimeline connection property app_name is renamed to display_key (it carries the events module's display_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.
  • #303 b4aad44 Thanks @SamTolmay! - Breaking: request_stages.filter_match takes plain MongoDB $match clauses instead of Atlas Search compound clauses. In contacts, companies and activities the structural filters moved out of $search into a standard $match, so the consumer filter hook is now standard query syntax and applies in both search modes. The var stays an array; only each element's syntax changes, and the clauses are composed via $and rather than shallow-merged, so a clause using $or is safe.

    These modules also gain a boolean atlas_search var (default true). Leave it true and text search runs through Atlas $search as before. Set it false — on contacts, companies, activities and deals — and text search falls back to a case-insensitive $regex $or over the same fields, which runs on any MongoDB: identical substring matching, but no relevance ranking and an unindexed collection scan, so it suits development and small collections rather than production at scale.

    With atlas_search: true the $search stage is unconditional — it runs on every list load, empty search box included, and only its contents vary. This is required by the tenant wall rather than chosen: $search must be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declares tenant: authored and carries the clause in compound.filter instead. That declaration is static per request while the search term is runtime, so a $search emitted only when a term happened to be present would be audited on the search path and refused on the browse path. The term clauses therefore sit in compound.must (a should alongside a filter clause ranks without narrowing, which would return every row for any term), and compound.filter always carries one clause — the audited organizationId equality under tenant, an exists match-all on _id under pinned, since Atlas refuses a compound whose clause lists are all empty. With atlas_search: false there is no $search at all and the wall scopes the leading $match mechanically.

    This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's default index to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two paths compound.filter needs — organizationId as a token (the authored tenant clause; a string equals requires that mapping specifically and no dynamic setting creates it) and _id (the pinned match-all). The structural filters are no longer evaluated by mongot, so their old token/date mappings are unused. storedSource now differs per collection: whole-document stored source is required on user-contacts and companies, and not required on activities and deals, whose requests read live documents so their post-write refetches do not show stale rows.

    Migration for consumers:

    • Rewrite any request_stages.filter_match clauses in MongoDB query syntax — { equals: { path: region, value: "x" } } becomes { region: "x" }, { range: { path: score, gte: 10 } } becomes { score: { $gte: 10 } }. The default [] is unaffected, so an app that never set the var has nothing to do.
    • For a non-Atlas deployment, set atlas_search: false on each searchable module entry (contacts, companies, activities, deals). Hold the value once in a shared app config file and _ref it from each entry, so the app has one source of truth for which mode it is in.
    • Create or update the default Atlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.md and docs/deals/index.md): whole-document storedSource: true on user-contacts and companies, no storedSource on activities and deals. Map organizationId as a token and _id in every one of them, whichever organizations policy the app runs — they are inert under pinned, and an index missing the organizationId mapping blanks every list page the moment a deployment flips to tenant, silently. deals needs a default index created from scratch: none existed before, so its search box has never worked, and the unconditional $search means the deal list now needs one to load at all. If companies.name_field is overridden, map that field in the companies index instead of name — otherwise Atlas $search silently returns no text matches on it while the regex fallback still works.
    • Before running the fallback at any size, add the regular mongod indexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.

    user-admin is not affected by the filter_match change — its clauses were already plain $match. Two non-breaking improvements land there: its members and invitations searches now treat regex metacharacters typed into the search box as literals (previously a ( made MongoDB reject the regex and .* matched every row), and both filter stages compose their clauses with $and so no two can collide on a key.

    The same escaping fix lands on deals' task Person Responsible selector, whose contact search had the same defect. All four search boxes now route through one shared clause builder, so escaping has a single home.

Patch Changes

  • #277 8ca77a4 Thanks @SamTolmay! - Pages whose title comes from a runtime operator no longer show "[object Object]" as the browser-tab title. The server writes the tab title without evaluating operators, so the layout page component now uses title only when it is a string at build time, and otherwise the page's type (for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build their type at build time, so their tab reads, for example, "Edit Company".

  • @lowdefy/modules-mongodb-events: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #127 b8a2ef6 Thanks @SamTolmay! - Breaking: the app_name module var is replaced by Lowdefy's _app operator. Apps now declare their identity once, as slug: on the root of lowdefy.yaml, and every module reads it directly with _build.app: slug.

    Migration for consumers:

    • Add slug: <your-app-slug> to the root of lowdefy.yaml if it isn't there already. It must be kebab-case (^[a-z][a-z0-9]*(-[a-z0-9]+)*$) — Lowdefy validates it, and referencing an undeclared slug fails the build.
    • Remove the app_name: var from every module entry. activities, companies, contacts, deals, notifications, user-account, user-admin and workflows no longer declare it.
    • events.display_key is now optional, defaulting to the app's own slug. Drop it unless the app deliberately renders another app's event display strings.
    • If a change_stamp override baked in an app-attribution literal (app_name: my-app), change the value to { _build.app: slug }. The stamp field keeps the name app_name.
    • Any shared config file that existed only to hold app_name (e.g. an app_config.yaml) can be deleted.

    No data migration is required — the slug produces the same string the app_name var did, and no stored field or key is renamed. created.app_name keeps its name on event and notification documents.

    Connection schema changes in @lowdefy/modules-mongodb-plugins: the WorkflowAPI connection property app_name is renamed to slug, and the EventsTimeline connection property app_name is renamed to display_key (it carries the events module's display_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys.

  • #303 f7659a7 Thanks @SamTolmay! - A person's contact links to their auth user through user-contacts.user_id. A contact belongs to one organization, while the auth user is global, so the link lives on the contact and a person holds one contact per organization.

    • The caller's own contact is the one with { organization_id, user_id }: the organization from the tenant wall and the user from the session. The shared modules/shared/contact/resolve-own-contact.yaml fragment resolves it. If the person has no linked contact there, it claims the unlinked contact for their verified address and stamps user_id. A contact already linked to someone else is never claimed. After the claim, identity is by id, so an email change does not break the link.
    • modules/shared/contact/ensure-contact.yaml makes sure a contact exists and is safe to run on every sign-in. It sets user_id only when there is an auth user, so a contact minted for an invitee who has no login yet is unlinked until they first sign in.
    • Joins from users to user-contacts match through modules/shared/contact/contact-match-expr.yaml: user_id, or the address while the contact is still unclaimed.
    • The events timeline (GetEventsTimeline) finds an author's picture through the contact's user_id. Under policy: tenant only the caller's organization's contact is read. An author whose contact has no user_id yet shows initials.
    • App config that needs the caller's contact should resolve it with resolve-own-contact, or match { organization_id, user_id } in a join.

    New required index on user-contacts: { organization_id: 1, user_id: 1 }, unique, partial on user_id: { $exists: true }, alongside the partial-unique { organization_id: 1, lowercase_email: 1 }. Under pinned the single-field { user_id: 1 } and { lowercase_email: 1 } work too. See docs/user-account/reference/indexes.md. No backfill is needed: an existing contact is claimed on its person's next sign-in.

    Contacts are minted and claimed only for a verified email. Turn on requireEmailVerification so that an unverified signup holds no session.

  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.
  • @lowdefy/modules-mongodb-files: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.
  • @lowdefy/modules-mongodb-layout: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #197 04d9a26 Thanks @Gervwyk! - App-wide seams on the layout page component. New module vars apply to every page that uses the page component, so an app wires a widget or a style once on its layout entry instead of on each page:

    • global_blocks: blocks appended after the page content, for floating widgets such as a help button or an assistant launcher.
    • global_requests: requests declared on every page for those blocks. They are not fired on mount.
    • global_subscriptions: websocket subscriptions declared on every page, such as a widget's live channel. The page component also takes a subscriptions var for a page's own; a page that subscribes to a websocket global_subscriptions also names keeps its own, so it holds one channel.
    • global_events.onInit / global_events.onMountAsync: actions spliced into every page, the module-level counterpart of the per-page events var. onMountAsync runs after the header_extra request fetches and before the page's own actions, so an app can hang an app-wide guard, such as an onboarding gate, there.
    • content_style: the content area style for every page, such as a different gutter (padding: 0 24px 24px 24px). A page's own content_style applies over it key by key, and a full_bleed page keeps zero padding.

    A page can also replace the module-wide header blocks with the header_blocks var (header_blocks: [] for a bare header).

  • #303 f7659a7 Thanks @SamTolmay! - The notifications inbox is redesigned, and notifications are live.

    • Inbox. The all page fills the screen with a rail of views and types, each with its unread count, a list of up to 1000 notifications that scrolls on its own, and a reading pane. Filters are Unread / All / Read, a date range, a search over title and preview, and a view or type from the rail. The new views var groups notification types, such as Mentions, at the top of the rail. Below tablet width the rail becomes a View selector and the list and pane take turns.
    • Mark all as read follows the filters. It marks only the unread notifications the filters match, across all pages, and names the count whenever a view, type, search or date narrows it. A notification can be marked unread again from the pane.
    • The reading pane shows the message, not the email. The stored email's canvas, card, logo, sign-off and footer are hidden, and the message takes the app's typography. Emails themselves are unchanged.
    • Appearance per type. Each type takes its icon and colour from the app's event_types entry; a type with none gets a palette colour chosen consistently from its id. Every colour follows the app's theme, dark mode included.
    • Live. The bell, the toasts and the inbox update the moment a notification arrives or is read in another tab or device, through the module's notifications-live websocket. The layout page component subscribes on every page with a signed-in user, unless the page passes hide_notifications: true. Each signed-in user with a page open holds one change stream and one pooled MongoDB connection, so size maxPoolSize for concurrent users. Change streams need a replica set; where they cannot run, set the new live var to false and the bell and toasts update on navigation.
    • Toasts. A notification flagged popup: true toasts the moment it arrives. The toast is 360px wide under the header, with the type's icon, a one-line title, a two-line preview, the time, Dismiss and Open (now also for links stored at data.links.button). The dispatch pipeline stores popup from the item, default false.
    • Sound. An opt-in chirp plays once per batch of popup notifications, after the viewer turns on Sound in the inbox header; the setting is kept per device. The module now declares @lowdefy/modules-mongodb-plugins for the NotificationSound block.
    • A record's type reads type before event_type everywhere, so a record carrying both is filtered, counted and labelled under its notification id.
  • #303 f7659a7 Thanks @SamTolmay! - Module links follow Lowdefy page paths and basePath. No module builds a page URL by hand any more, so nothing breaks when a page has a path or the app has a basePath.

    • Pages with paths. The layout page component takes a path var, such as tickets/{ticket_id}, to serve the page at a URL pattern.
    • Record links. Links in HTML (company and contact lists, the activities timeline, the company hierarchy, the deal card and the deal's people, the workflow open-actions card and the overview subtitle) are data-page-id links, so they move within the app without a full page load. DataDescriptions and SmartDescriptions render contact and company links with Lowdefy's Link, and ActionSteps, WorkflowProgress and EventsTimeline pass a link's pathParams to it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id.
    • Workflows. A workflow's entity: block takes id_path_key, the entity page's path placeholder for the entity id (for a page at deals/{deal_id}, id_path_key: deal_id); the entity link then carries the id in pathParams. id_query_key defaults to _id only when id_path_key is not set, so set both to also pass the id in the query. tracker.start_link and the custom-kind status_map link: / view_link: cells take pathParams next to urlQuery, with the same action_id: true / entity_id: true sentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items pass pathParams too.
    • Breaking (events): contact_page_url is replaced by contact_page_id. Set it to the contact page's id, such as contacts/view. The contact id goes in the query as contact_id_query_key (default _id), in the path value contact_id_path_key names when the page has a path like contacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and the EventsTimeline block takes contactPageId, contactIdQueryKey and contactIdPathKey in place of contactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page; note-capture stores each @mention as a #contact-<id> link, and mentions in notes saved before this show unlinked.
    • Breaking (ai-reporting): generate-report, create-report and duplicate-report return { ok, report_id } without url. Open a report with a Link to the report page and urlQuery: { report_id }. The reporting agent points the user to the panel's "Reports from this chat" list instead of replying with a link.
    • Fix (deals): deal event titles name the user as plain text. The old link pointed at /contacts/view with the user's id, which is not a contact id.

Patch Changes

  • #277 8ca77a4 Thanks @SamTolmay! - Pages whose title comes from a runtime operator no longer show "[object Object]" as the browser-tab title. The server writes the tab title without evaluating operators, so the layout page component now uses title only when it is a string at build time, and otherwise the page's type (for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build their type at build time, so their tab reads, for example, "Edit Company".

  • @lowdefy/modules-mongodb-notifications: ### Major Changes

  • #303 f7659a7 Thanks @SamTolmay! - Breaking: the modules need Lowdefy 7. This release moves every module and the plugins package to Lowdefy 7 and its auth engine, page paths, tenant wall, websockets and Lucide icons. Apps on Lowdefy 5 or 6 stay on the previous release.

    To upgrade, move the app to Lowdefy 7 (lowdefy upgrade applies the framework codemods), then work through the breaking entries in this release, chiefly:

    • Icon names are Lowdefy 7 semantic and Lucide names; icon names an app passes into module vars need the same rewrite.
    • app_name is gone. Declare slug: on the root of lowdefy.yaml and drop the var from every module entry.
    • user-account and user-admin run on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.
    • Search: request_stages.filter_match takes plain $match clauses, the new atlas_search var picks Atlas $search or a regex fallback, and the Atlas Search index contract changes.
    • Events: contact_page_url is replaced by contact_page_id.
    • ai-reporting: the app lists the module's report-policy in policies.dynamicBlocks, and the report endpoints return report_id without url.
    • profile.picture is derived by every write, and create-contact mints the contact _id itself.

Minor Changes

  • #127 b8a2ef6 Thanks @SamTolmay! - Breaking: the app_name module var is replaced by Lowdefy's _app operator. Apps now declare their identity once, as slug: on the root of lowdefy.yaml, and every module reads it directly with _build.app: slug.

    Migration for consumers:

    • Add slug: <your-app-slug> to the root of lowdefy.yaml if it isn't there already. It must be kebab-case (^[a-z][a-z0-9]*(-[a-z0-9]+)*$) — Lowdefy validates it, and referencing an undeclared slug fails the build.
    • Remove the app_name: var from every module entry. activities, companies, contacts, deals, notifications, user-account, user-admin and workflows no longer declare it.
    • events.display_key is now optional, defaulting to the app's own slug. Drop it unless the app deliberately renders another app's event display strings.
    • If a change_stamp override baked in an app-attribution literal (app_name: my-app), change the value to { _build.app: slug }. The stamp field keeps the name app_name.
    • Any shared config file that existed only to hold app_name (e.g. an app_config.yaml) can be deleted.

    No data migration is required — the slug produces the same string the app_name var did, and no stored field or key is renamed. created.app_name keeps its name on event and notification documents.

    Connection schema changes in @lowdefy/modules-mongodb-plugins: the WorkflowAPI connection property app_name is renamed to slug, and the EventsTimeline connection property app_name is renamed to display_key (it carries the events module's display_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys.

  • #242 9e083dc Thanks @SamTolmay! - Breaking: icon names are Lowdefy 7 semantic and Lucide names. Lowdefy 7 replaces react-icons with Lucide and fails the build on a react-icons name, so every AiOutline*, AiFill* and Lu* name in the modules and the plugin blocks is rewritten with the @lowdefy/codemods v7-0-0/react-icons-to-lucide.json table: a semantic name (edit, delete, mail) where one fits, otherwise the Lucide name (Waypoints, FileSpreadsheet).

    The modules now need a Lowdefy version with Lucide icons. Glyphs the table marks for review: filled status icons (check-circle, close-circle, CircleAlert) are outline, the FileManager's PDF and Word icons are both FileText and its Excel icon is FileSpreadsheet, and the Cluster icon is Waypoints.

    Apps that pass icon names into module vars (menu entries, enum icon fields, event_types) need the same rewrite; lowdefy upgrade applies it.

  • #303 f7659a7 Thanks @SamTolmay! - The notifications module owns the dispatch pipeline around Lowdefy's notification render. Lowdefy renders notification emails from the app's notifications: config through the RenderNotification step; the module composes the rest. See docs/notifications/index.md.

    • dispatch-notifications (exported InternalApi, payload { notification_id, items }): per item it mints the record id, renders the app's template, inserts the record before sending (claiming the dedup key, so concurrent dispatches cannot double-send), sends, and records the result. A failed send never fails the dispatch: the record stays sent: false with send_attempts bumped. send_routine keeps its { event_ids } contract and typically ends in a CallApi to dispatch-notifications.
    • drain-notifications (exported InternalApi) re-sends records a failed send left unsent, from their stored render, claiming each with an optimistic lock so overlapping runs cannot double-send. The module ships no schedule: call it from a cron-only endpoint of the app's own. Under policy: tenant it retries each record bound to its organization_id, which Lowdefy accepts only in a trusted system run, so call it from a schedule.
    • Transports. transport: smtp (default, the notifications-email connection, configured by the email vars with the NOTIFICATIONS_SMTP_PASS secret) or sendgrid (notifications-email-sendgrid, the sendgrid vars with SENDGRID_API_KEY). Either connection can be remapped to the app's own.
    • Recipient filters. email.filter and sendgrid.filter take { replaceAddress, allowlist, regex }. Left null they fall back to the current Lowdefy environment's email.filter, and server_url (the origin of email links) falls back to the environment's url, so an app that declares config.environments can leave them unset. Types in the new filter_exempt_types var (default: the pre-auth invite types) always reach the real recipient. email_result on the record shows where the mail actually went.
    • Links. A notification link is { pageId, pathParams?, urlQuery?, input? }; the landing page and the inbox's View button follow pathParams. public_link_types names the link types the link page resolves without a session.
    • Public pages. The manifest contributes link, invalid and file-download to auth.pages.public, so they can come out of the app's own list. The inbox (all) stays protected.
    • Tenant wall. Every notification write keeps the wall; none uses tenant: none.

    Required index: dedup needs a unique partial index on key (notification_key_unique, partial on key: { $type: 'string' }). Without it the guarantee does not exist. Two read indexes on user_id / contact_id with created.app_name and created.timestamp keep the inbox fast; see the module docs.

    Auth emails (verify, magic link, invitation) are sent by the auth engine through auth.email, not through this pipeline.

  • #303 f7659a7 Thanks @SamTolmay! - The notifications inbox is redesigned, and notifications are live.

    • Inbox. The all page fills the screen with a rail of views and types, each with its unread count, a list of up to 1000 notifications that scrolls on its own, and a reading pane. Filters are Unread / All / Read, a date range, a search over title and preview, and a view or type from the rail. The new views var groups notification types, such as Mentions, at the top of the rail. Below tablet width the rail becomes a View selector and the list and pane take turns.
    • Mark all as read follows the filters. It marks only the unread