Repository navigation
v1.0.0
-
@lowdefy/modules-mongodb-plugins: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#127
b8a2ef6Thanks @SamTolmay! - Breaking: theapp_namemodule var is replaced by Lowdefy's_appoperator. Apps now declare their identity once, asslug:on the root oflowdefy.yaml, and every module reads it directly with_build.app: slug.Migration for consumers:
- Add
slug: <your-app-slug>to the root oflowdefy.yamlif 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-adminandworkflowsno longer declare it. events.display_keyis 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_stampoverride baked in an app-attribution literal (app_name: my-app), change the value to{ _build.app: slug }. The stamp field keeps the nameapp_name. - Any shared config file that existed only to hold
app_name(e.g. anapp_config.yaml) can be deleted.
No data migration is required — the slug produces the same string the
app_namevar did, and no stored field or key is renamed.created.app_namekeeps its name on event and notification documents.Connection schema changes in
@lowdefy/modules-mongodb-plugins: theWorkflowAPIconnection propertyapp_nameis renamed toslug, and theEventsTimelineconnection propertyapp_nameis renamed todisplay_key(it carries the events module'sdisplay_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys. - Add
-
#303
240e981Thanks @SamTolmay! - Breaking: the profile name and avatar are derived on the server when a profile is written.contacts'create-contactandupdate-contact, and the sharedwrite-profilefragment thatuser-accountanduser-adminuse, all call one derivation (modules/shared/profile/derive-profile.js.njk). It setsprofile.name,profile.avatar_colorandprofile.picture.- A payload's
profile.pictureis 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.nameis 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_coloris 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, clearprofile.avatar_colorin a migration. Seedocs/shared/avatar-colors.md. - A profile with no name stores no
picture, so the Avatar block shows its person icon. - Profile photos. The new
AvatarUploadplugin 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 toprofile.photo, and an uploaded photo becomes the storedprofile.picture. Removing the photo goes back to the initials avatar.update-profilerejects a photo that is not an image data URI or is over about 512 KB. create-contactmints the contact_idon the server and ignores a payload_id. Read the new id from the response'scontactId. A payload carrying a known_idwith 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$(agiven_nameof$email, or an email local part) is stored as written instead of being read as a field path.
request_stages.writestages still run last, so a stage that overridesprofile.nameorprofile.picturestill wins. - A payload's
-
#303
f7659a7Thanks @SamTolmay! - A person's contact links to their auth user throughuser-contacts.user_id. A contact belongs to one organization, while the authuseris 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 sharedmodules/shared/contact/resolve-own-contact.yamlfragment resolves it. If the person has no linked contact there, it claims the unlinked contact for their verified address and stampsuser_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.yamlmakes sure a contact exists and is safe to run on every sign-in. It setsuser_idonly 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
userstouser-contactsmatch throughmodules/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'suser_id. Underpolicy: tenantonly the caller's organization's contact is read. An author whose contact has nouser_idyet 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 onuser_id: { $exists: true }, alongside the partial-unique{ organization_id: 1, lowercase_email: 1 }. Underpinnedthe single-field{ user_id: 1 }and{ lowercase_email: 1 }work too. Seedocs/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
requireEmailVerificationso that an unverified signup holds no session. - The caller's own contact is the one with
-
#244
f6d14d8Thanks @SamTolmay! - FileManager can take its upload policy from the page's own actions instead of a page request.When a page defines the new
onUploadPolicyevent, the block triggers it before each upload in place of thes3PostPolicyRequestIdrequest, with_eventset 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 newsetUploadPolicymethod. 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 (setUploadPolicywith the policy and the file'suid) or drop it (the newcancelUploadmethod with theuid, which from the event itself drops the upload instead of holding it). A file pasted from the clipboard carriespasted: truein the policy event and ononSave'sfile. Apps that sets3PostPolicyRequestIdand define noonUploadPolicybehave as before.Each upload now keeps its own thumbnail, so two uploads started close together no longer swap thumbnails on
onSave. -
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it. -
#300
d2642aeThanks @Gervwyk! - NewNotificationSoundblock: 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'slocalStorage, so it holds per device and other tabs follow a change at once. Withouttogglethe block draws nothing and only plays, through itsplay(ids)method. -
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
-
#286
497fc1fThanks @Gervwyk! - NewChatThreadblock. 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. Seedocs/plugins/chat-thread.md. -
#290
08abd4dThanks @Gervwyk! - Pages that use the plugin connections now run in Lowdefy data-set journeys.EventsTimeline,WorkflowAPIandReportingDatadeclaremeta.dataSet: redirect, so a journey points them at its own database.AiTextdeclaresmeta.dataSet: externaland keeps calling the AI Gateway.- These connections share one MongoDB client per URI but open the connection's
databaseNameon every request. Before, the first database opened on a URI was reused for every later request on it, whateverdatabaseNameit named.
-
#294
b2e7950Thanks @Gervwyk! - NewMessageComposerblock. 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. Seedocs/plugins/message-composer.md. -
#286
e4dc983Thanks @Gervwyk! - NewSupportContextblock,_support_contextoperator and error recorder. When the plugin's blocks or the operator load, a recorder onwindowstarts keeping the tab's last 20console.errorcalls, uncaught errors and unhandled rejections, in memory only. TheSupportContextblock renders nothing; itssnapshotmethod sets its value to the page URL, user agent, viewport, screen, locale, time zone and those errors, and firesonSnapshot, for a support form to send with a ticket. The_support_contextoperator gives the same snapshot with the page id as a value, for an agent's shared state. Seedocs/plugins/support-context.md. -
#286
0f463b9Thanks @Gervwyk! - NewSupportScreenshotblock. 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 everymaskSelectorsmatch drawn as a solid box, andhideSelectorsleft out. The user can blur or crop the result before it is uploaded through an S3 presigned POST policy request;onUsefires with the uploaded file only after S3 stores it. WithattachTo, 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. Seedocs/plugins/support-screenshot.md. Addsmodern-screenshotas a dependency. -
#286
cb004d1Thanks @Gervwyk! - NewSupportWebhookconnection with aSupportWebhookVerifyrequest. It checks that a webhook delivery from Pelican's support desk was signed with the app's webhook secret: anysha256=<hex>entry inPelican-Signaturemust 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'swebhook: { verify }gate, and fails verification rather than throwing on bad input. Seedocs/plugins/support-webhook.md.
Patch Changes
-
#253
5e4d09eThanks @SamTolmay! - The ai-assistant module installs underauth.organizations.policy: tenant. The threads connection is walled like any MongoDB connection, so each organization keeps its own thread history. TheAiTextconnection (thread titling) holds no data and declares itself non-scopable (tenant: false). -
#266
97ba374Thanks @SamTolmay! - A contact with one name part is labelled correctly in contact lists. TheContactSelectorblock'sappendContactmethod 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 asThandi undefinedorundefined 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
2ed0457Thanks @Gervwyk! - A refused upload is an error, not a saved file.FileManagertreated 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 firedonSavefor 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 noonSave. -
#266
31984ebThanks @SamTolmay! - Fix: report rows can no longer run as operators.compileReportnow 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,_stateor__apiran as a live operator in the viewer's browser._idis not an operator, so a$groupresult 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
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#127
b8a2ef6Thanks @SamTolmay! - Breaking: theapp_namemodule var is replaced by Lowdefy's_appoperator. Apps now declare their identity once, asslug:on the root oflowdefy.yaml, and every module reads it directly with_build.app: slug.Migration for consumers:
- Add
slug: <your-app-slug>to the root oflowdefy.yamlif 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-adminandworkflowsno longer declare it. events.display_keyis 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_stampoverride baked in an app-attribution literal (app_name: my-app), change the value to{ _build.app: slug }. The stamp field keeps the nameapp_name. - Any shared config file that existed only to hold
app_name(e.g. anapp_config.yaml) can be deleted.
No data migration is required — the slug produces the same string the
app_namevar did, and no stored field or key is renamed.created.app_namekeeps its name on event and notification documents.Connection schema changes in
@lowdefy/modules-mongodb-plugins: theWorkflowAPIconnection propertyapp_nameis renamed toslug, and theEventsTimelineconnection propertyapp_nameis renamed todisplay_key(it carries the events module'sdisplay_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys. - Add
-
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it. -
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
-
#303
b4aad44Thanks @SamTolmay! - Breaking:request_stages.filter_matchtakes plain MongoDB$matchclauses instead of Atlas Searchcompoundclauses. Incontacts,companiesandactivitiesthe structural filters moved out of$searchinto 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$andrather than shallow-merged, so a clause using$oris safe.These modules also gain a boolean
atlas_searchvar (defaulttrue). Leave ittrueand text search runs through Atlas$searchas before. Set itfalse— oncontacts,companies,activitiesanddeals— and text search falls back to a case-insensitive$regex$orover 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: truethe$searchstage 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:$searchmust be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declarestenant: authoredand carries the clause incompound.filterinstead. That declaration is static per request while the search term is runtime, so a$searchemitted 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 incompound.must(ashouldalongside afilterclause ranks without narrowing, which would return every row for any term), andcompound.filteralways carries one clause — the auditedorganizationIdequality undertenant, anexistsmatch-all on_idunderpinned, since Atlas refuses a compound whose clause lists are all empty. Withatlas_search: falsethere is no$searchat all and the wall scopes the leading$matchmechanically.This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's
defaultindex to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two pathscompound.filterneeds —organizationIdas atoken(the authored tenant clause; a stringequalsrequires that mapping specifically and nodynamicsetting creates it) and_id(thepinnedmatch-all). The structural filters are no longer evaluated bymongot, so their oldtoken/datemappings are unused.storedSourcenow differs per collection: whole-document stored source is required onuser-contactsandcompanies, and not required onactivitiesanddeals, whose requests read live documents so their post-write refetches do not show stale rows.Migration for consumers:
- Rewrite any
request_stages.filter_matchclauses 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: falseon each searchable module entry (contacts,companies,activities,deals). Hold the value once in a shared app config file and_refit from each entry, so the app has one source of truth for which mode it is in. - Create or update the
defaultAtlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.mdanddocs/deals/index.md): whole-documentstoredSource: trueonuser-contactsandcompanies, nostoredSourceonactivitiesanddeals. MaporganizationIdas atokenand_idin every one of them, whichever organizations policy the app runs — they are inert underpinned, and an index missing theorganizationIdmapping blanks every list page the moment a deployment flips totenant, silently.dealsneeds adefaultindex created from scratch: none existed before, so its search box has never worked, and the unconditional$searchmeans the deal list now needs one to load at all. Ifcompanies.name_fieldis overridden, map that field in the companies index instead ofname— otherwise Atlas$searchsilently returns no text matches on it while the regex fallback still works. - Before running the fallback at any size, add the regular
mongodindexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.
user-adminis not affected by thefilter_matchchange — 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$andso 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. - Rewrite any
Patch Changes
-
#266
97ba374Thanks @SamTolmay! - A contact with one name part is labelled correctly in contact lists. TheContactSelectorblock'sappendContactmethod 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 asThandi undefinedorundefined 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
8ca77a4Thanks @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 usestitleonly when it is a string at build time, and otherwise the page'stype(for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build theirtypeat build time, so their tab reads, for example, "Edit Company". -
@lowdefy/modules-mongodb-ai-assistant: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#294
32f79c2Thanks @Gervwyk! - Take a screenshot from the assistant's chat panel. Withscreenshot: true, the panel gets a Take screenshot button. It captures the page behind the panel with password inputs,[data-support-mask]elements and thescreenshot_mask_selectorsvar'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 throughsender_attachments, so attachments must be on and the app's upload policy must accept an image. Not shown in the embedded shell. -
#287
c6ea2f1Thanks @Gervwyk! - File attachments in the assistant's chat, with stored file links signed again on open.sender_attachmentsturns on file attachments in the composer. It is passed through as the chat block'ssender.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_endpointnames 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
0934897Thanks @SamTolmay! - Newopen-with-promptcomponent: an action list for a button anywhere on a page that mountspanel. It opens the panel on a new thread and sends itspromptvar (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 runon_before_send. -
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it.
Patch Changes
-
#253
19cf65aThanks @SamTolmay! - Threads save again when thetagvar is left unset, and thread titling runs whentitle_contextis left unset. Both vars default to null, and thesave-threadandtitle-threadendpoints refused a nulltagorcontextagainst their payload schemas, so an app that set neither never persisted a thread. -
#253
5e4d09eThanks @SamTolmay! - The ai-assistant module installs underauth.organizations.policy: tenant. The threads connection is walled like any MongoDB connection, so each organization keeps its own thread history. TheAiTextconnection (thread titling) holds no data and declares itself non-scopable (tenant: false). -
@lowdefy/modules-mongodb-ai-reporting: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#266
6617388Thanks @SamTolmay! - Breaking: the report page renders under a dynamic blocks policy the app must list. On Lowdefy v7 aDynamicblock renders blocks that its endpoint built from data only under a dynamic blocks policy, through aValidateDynamicstep. 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 thereport-policycomponent, thereportpage'sDynamicblock names it, andresolve-reportchecks the compiled blocks withValidateDynamicbefore 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: falseand 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 MBlimits.bytesrenders 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, andquery-dataandchart-dataaccept that form and load the query server-side. -
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it. -
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
-
@lowdefy/modules-mongodb-companies: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#127
b8a2ef6Thanks @SamTolmay! - Breaking: theapp_namemodule var is replaced by Lowdefy's_appoperator. Apps now declare their identity once, asslug:on the root oflowdefy.yaml, and every module reads it directly with_build.app: slug.Migration for consumers:
- Add
slug: <your-app-slug>to the root oflowdefy.yamlif 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-adminandworkflowsno longer declare it. events.display_keyis 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_stampoverride baked in an app-attribution literal (app_name: my-app), change the value to{ _build.app: slug }. The stamp field keeps the nameapp_name. - Any shared config file that existed only to hold
app_name(e.g. anapp_config.yaml) can be deleted.
No data migration is required — the slug produces the same string the
app_namevar did, and no stored field or key is renamed.created.app_namekeeps its name on event and notification documents.Connection schema changes in
@lowdefy/modules-mongodb-plugins: theWorkflowAPIconnection propertyapp_nameis renamed toslug, and theEventsTimelineconnection propertyapp_nameis renamed todisplay_key(it carries the events module'sdisplay_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys. - Add
-
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it. -
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
-
#303
b4aad44Thanks @SamTolmay! - Breaking:request_stages.filter_matchtakes plain MongoDB$matchclauses instead of Atlas Searchcompoundclauses. Incontacts,companiesandactivitiesthe structural filters moved out of$searchinto 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$andrather than shallow-merged, so a clause using$oris safe.These modules also gain a boolean
atlas_searchvar (defaulttrue). Leave ittrueand text search runs through Atlas$searchas before. Set itfalse— oncontacts,companies,activitiesanddeals— and text search falls back to a case-insensitive$regex$orover 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: truethe$searchstage 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:$searchmust be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declarestenant: authoredand carries the clause incompound.filterinstead. That declaration is static per request while the search term is runtime, so a$searchemitted 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 incompound.must(ashouldalongside afilterclause ranks without narrowing, which would return every row for any term), andcompound.filteralways carries one clause — the auditedorganizationIdequality undertenant, anexistsmatch-all on_idunderpinned, since Atlas refuses a compound whose clause lists are all empty. Withatlas_search: falsethere is no$searchat all and the wall scopes the leading$matchmechanically.This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's
defaultindex to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two pathscompound.filterneeds —organizationIdas atoken(the authored tenant clause; a stringequalsrequires that mapping specifically and nodynamicsetting creates it) and_id(thepinnedmatch-all). The structural filters are no longer evaluated bymongot, so their oldtoken/datemappings are unused.storedSourcenow differs per collection: whole-document stored source is required onuser-contactsandcompanies, and not required onactivitiesanddeals, whose requests read live documents so their post-write refetches do not show stale rows.Migration for consumers:
- Rewrite any
request_stages.filter_matchclauses 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: falseon each searchable module entry (contacts,companies,activities,deals). Hold the value once in a shared app config file and_refit from each entry, so the app has one source of truth for which mode it is in. - Create or update the
defaultAtlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.mdanddocs/deals/index.md): whole-documentstoredSource: trueonuser-contactsandcompanies, nostoredSourceonactivitiesanddeals. MaporganizationIdas atokenand_idin every one of them, whichever organizations policy the app runs — they are inert underpinned, and an index missing theorganizationIdmapping blanks every list page the moment a deployment flips totenant, silently.dealsneeds adefaultindex created from scratch: none existed before, so its search box has never worked, and the unconditional$searchmeans the deal list now needs one to load at all. Ifcompanies.name_fieldis overridden, map that field in the companies index instead ofname— otherwise Atlas$searchsilently returns no text matches on it while the regex fallback still works. - Before running the fallback at any size, add the regular
mongodindexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.
user-adminis not affected by thefilter_matchchange — 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$andso 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. - Rewrite any
Patch Changes
-
#266
97ba374Thanks @SamTolmay! - A contact with one name part is labelled correctly in contact lists. TheContactSelectorblock'sappendContactmethod 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 asThandi undefinedorundefined 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
8ca77a4Thanks @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 usestitleonly when it is a string at build time, and otherwise the page'stype(for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build theirtypeat build time, so their tab reads, for example, "Edit Company". -
@lowdefy/modules-mongodb-contacts: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#127
b8a2ef6Thanks @SamTolmay! - Breaking: theapp_namemodule var is replaced by Lowdefy's_appoperator. Apps now declare their identity once, asslug:on the root oflowdefy.yaml, and every module reads it directly with_build.app: slug.Migration for consumers:
- Add
slug: <your-app-slug>to the root oflowdefy.yamlif 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-adminandworkflowsno longer declare it. events.display_keyis 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_stampoverride baked in an app-attribution literal (app_name: my-app), change the value to{ _build.app: slug }. The stamp field keeps the nameapp_name. - Any shared config file that existed only to hold
app_name(e.g. anapp_config.yaml) can be deleted.
No data migration is required — the slug produces the same string the
app_namevar did, and no stored field or key is renamed.created.app_namekeeps its name on event and notification documents.Connection schema changes in
@lowdefy/modules-mongodb-plugins: theWorkflowAPIconnection propertyapp_nameis renamed toslug, and theEventsTimelineconnection propertyapp_nameis renamed todisplay_key(it carries the events module'sdisplay_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys. - Add
-
#303
240e981Thanks @SamTolmay! - Breaking: the profile name and avatar are derived on the server when a profile is written.contacts'create-contactandupdate-contact, and the sharedwrite-profilefragment thatuser-accountanduser-adminuse, all call one derivation (modules/shared/profile/derive-profile.js.njk). It setsprofile.name,profile.avatar_colorandprofile.picture.- A payload's
profile.pictureis 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.nameis 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_coloris 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, clearprofile.avatar_colorin a migration. Seedocs/shared/avatar-colors.md. - A profile with no name stores no
picture, so the Avatar block shows its person icon. - Profile photos. The new
AvatarUploadplugin 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 toprofile.photo, and an uploaded photo becomes the storedprofile.picture. Removing the photo goes back to the initials avatar.update-profilerejects a photo that is not an image data URI or is over about 512 KB. create-contactmints the contact_idon the server and ignores a payload_id. Read the new id from the response'scontactId. A payload carrying a known_idwith 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$(agiven_nameof$email, or an email local part) is stored as written instead of being read as a field path.
request_stages.writestages still run last, so a stage that overridesprofile.nameorprofile.picturestill wins. - A payload's
-
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it. -
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
-
#303
b4aad44Thanks @SamTolmay! - Breaking:request_stages.filter_matchtakes plain MongoDB$matchclauses instead of Atlas Searchcompoundclauses. Incontacts,companiesandactivitiesthe structural filters moved out of$searchinto 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$andrather than shallow-merged, so a clause using$oris safe.These modules also gain a boolean
atlas_searchvar (defaulttrue). Leave ittrueand text search runs through Atlas$searchas before. Set itfalse— oncontacts,companies,activitiesanddeals— and text search falls back to a case-insensitive$regex$orover 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: truethe$searchstage 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:$searchmust be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declarestenant: authoredand carries the clause incompound.filterinstead. That declaration is static per request while the search term is runtime, so a$searchemitted 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 incompound.must(ashouldalongside afilterclause ranks without narrowing, which would return every row for any term), andcompound.filteralways carries one clause — the auditedorganizationIdequality undertenant, anexistsmatch-all on_idunderpinned, since Atlas refuses a compound whose clause lists are all empty. Withatlas_search: falsethere is no$searchat all and the wall scopes the leading$matchmechanically.This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's
defaultindex to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two pathscompound.filterneeds —organizationIdas atoken(the authored tenant clause; a stringequalsrequires that mapping specifically and nodynamicsetting creates it) and_id(thepinnedmatch-all). The structural filters are no longer evaluated bymongot, so their oldtoken/datemappings are unused.storedSourcenow differs per collection: whole-document stored source is required onuser-contactsandcompanies, and not required onactivitiesanddeals, whose requests read live documents so their post-write refetches do not show stale rows.Migration for consumers:
- Rewrite any
request_stages.filter_matchclauses 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: falseon each searchable module entry (contacts,companies,activities,deals). Hold the value once in a shared app config file and_refit from each entry, so the app has one source of truth for which mode it is in. - Create or update the
defaultAtlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.mdanddocs/deals/index.md): whole-documentstoredSource: trueonuser-contactsandcompanies, nostoredSourceonactivitiesanddeals. MaporganizationIdas atokenand_idin every one of them, whichever organizations policy the app runs — they are inert underpinned, and an index missing theorganizationIdmapping blanks every list page the moment a deployment flips totenant, silently.dealsneeds adefaultindex created from scratch: none existed before, so its search box has never worked, and the unconditional$searchmeans the deal list now needs one to load at all. Ifcompanies.name_fieldis overridden, map that field in the companies index instead ofname— otherwise Atlas$searchsilently returns no text matches on it while the regex fallback still works. - Before running the fallback at any size, add the regular
mongodindexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.
user-adminis not affected by thefilter_matchchange — 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$andso 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. - Rewrite any
Patch Changes
-
#277
8ca77a4Thanks @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 usestitleonly when it is a string at build time, and otherwise the page'stype(for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build theirtypeat build time, so their tab reads, for example, "Edit Company". -
@lowdefy/modules-mongodb-deals: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#127
b8a2ef6Thanks @SamTolmay! - Breaking: theapp_namemodule var is replaced by Lowdefy's_appoperator. Apps now declare their identity once, asslug:on the root oflowdefy.yaml, and every module reads it directly with_build.app: slug.Migration for consumers:
- Add
slug: <your-app-slug>to the root oflowdefy.yamlif 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-adminandworkflowsno longer declare it. events.display_keyis 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_stampoverride baked in an app-attribution literal (app_name: my-app), change the value to{ _build.app: slug }. The stamp field keeps the nameapp_name. - Any shared config file that existed only to hold
app_name(e.g. anapp_config.yaml) can be deleted.
No data migration is required — the slug produces the same string the
app_namevar did, and no stored field or key is renamed.created.app_namekeeps its name on event and notification documents.Connection schema changes in
@lowdefy/modules-mongodb-plugins: theWorkflowAPIconnection propertyapp_nameis renamed toslug, and theEventsTimelineconnection propertyapp_nameis renamed todisplay_key(it carries the events module'sdisplay_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys. - Add
-
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it. -
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
-
#303
b4aad44Thanks @SamTolmay! - Breaking:request_stages.filter_matchtakes plain MongoDB$matchclauses instead of Atlas Searchcompoundclauses. Incontacts,companiesandactivitiesthe structural filters moved out of$searchinto 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$andrather than shallow-merged, so a clause using$oris safe.These modules also gain a boolean
atlas_searchvar (defaulttrue). Leave ittrueand text search runs through Atlas$searchas before. Set itfalse— oncontacts,companies,activitiesanddeals— and text search falls back to a case-insensitive$regex$orover 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: truethe$searchstage 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:$searchmust be a pipeline's first stage, so the wall cannot prepend its organization filter and the request declarestenant: authoredand carries the clause incompound.filterinstead. That declaration is static per request while the search term is runtime, so a$searchemitted 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 incompound.must(ashouldalongside afilterclause ranks without narrowing, which would return every row for any term), andcompound.filteralways carries one clause — the auditedorganizationIdequality undertenant, anexistsmatch-all on_idunderpinned, since Atlas refuses a compound whose clause lists are all empty. Withatlas_search: falsethere is no$searchat all and the wall scopes the leading$matchmechanically.This version changes the Atlas Search index contract, so a consumer upgrading must update their cluster's
defaultindex to match the requirement documented in the version they move to. The mappings narrow to the text fields plus the two pathscompound.filterneeds —organizationIdas atoken(the authored tenant clause; a stringequalsrequires that mapping specifically and nodynamicsetting creates it) and_id(thepinnedmatch-all). The structural filters are no longer evaluated bymongot, so their oldtoken/datemappings are unused.storedSourcenow differs per collection: whole-document stored source is required onuser-contactsandcompanies, and not required onactivitiesanddeals, whose requests read live documents so their post-write refetches do not show stale rows.Migration for consumers:
- Rewrite any
request_stages.filter_matchclauses 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: falseon each searchable module entry (contacts,companies,activities,deals). Hold the value once in a shared app config file and_refit from each entry, so the app has one source of truth for which mode it is in. - Create or update the
defaultAtlas Search index per collection, following the module index references (docs/{contacts,companies,activities}/reference/indexes.mdanddocs/deals/index.md): whole-documentstoredSource: trueonuser-contactsandcompanies, nostoredSourceonactivitiesanddeals. MaporganizationIdas atokenand_idin every one of them, whichever organizations policy the app runs — they are inert underpinned, and an index missing theorganizationIdmapping blanks every list page the moment a deployment flips totenant, silently.dealsneeds adefaultindex created from scratch: none existed before, so its search box has never worked, and the unconditional$searchmeans the deal list now needs one to load at all. Ifcompanies.name_fieldis overridden, map that field in the companies index instead ofname— otherwise Atlas$searchsilently returns no text matches on it while the regex fallback still works. - Before running the fallback at any size, add the regular
mongodindexes the module index references list. They serve the filter, not the sort; without them a fallback deployment performs acceptably only at small scale.
user-adminis not affected by thefilter_matchchange — 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$andso 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. - Rewrite any
Patch Changes
-
#277
8ca77a4Thanks @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 usestitleonly when it is a string at build time, and otherwise the page'stype(for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build theirtypeat build time, so their tab reads, for example, "Edit Company". -
@lowdefy/modules-mongodb-events: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#127
b8a2ef6Thanks @SamTolmay! - Breaking: theapp_namemodule var is replaced by Lowdefy's_appoperator. Apps now declare their identity once, asslug:on the root oflowdefy.yaml, and every module reads it directly with_build.app: slug.Migration for consumers:
- Add
slug: <your-app-slug>to the root oflowdefy.yamlif 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-adminandworkflowsno longer declare it. events.display_keyis 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_stampoverride baked in an app-attribution literal (app_name: my-app), change the value to{ _build.app: slug }. The stamp field keeps the nameapp_name. - Any shared config file that existed only to hold
app_name(e.g. anapp_config.yaml) can be deleted.
No data migration is required — the slug produces the same string the
app_namevar did, and no stored field or key is renamed.created.app_namekeeps its name on event and notification documents.Connection schema changes in
@lowdefy/modules-mongodb-plugins: theWorkflowAPIconnection propertyapp_nameis renamed toslug, and theEventsTimelineconnection propertyapp_nameis renamed todisplay_key(it carries the events module'sdisplay_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys. - Add
-
#303
f7659a7Thanks @SamTolmay! - A person's contact links to their auth user throughuser-contacts.user_id. A contact belongs to one organization, while the authuseris 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 sharedmodules/shared/contact/resolve-own-contact.yamlfragment resolves it. If the person has no linked contact there, it claims the unlinked contact for their verified address and stampsuser_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.yamlmakes sure a contact exists and is safe to run on every sign-in. It setsuser_idonly 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
userstouser-contactsmatch throughmodules/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'suser_id. Underpolicy: tenantonly the caller's organization's contact is read. An author whose contact has nouser_idyet 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 onuser_id: { $exists: true }, alongside the partial-unique{ organization_id: 1, lowercase_email: 1 }. Underpinnedthe single-field{ user_id: 1 }and{ lowercase_email: 1 }work too. Seedocs/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
requireEmailVerificationso that an unverified signup holds no session. - The caller's own contact is the one with
-
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
-
@lowdefy/modules-mongodb-files: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
-
@lowdefy/modules-mongodb-layout: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#197
04d9a26Thanks @Gervwyk! - App-wide seams on the layoutpagecomponent. New module vars apply to every page that uses thepagecomponent, so an app wires a widget or a style once on itslayoutentry 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. Thepagecomponent also takes asubscriptionsvar for a page's own; a page that subscribes to a websocketglobal_subscriptionsalso 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-pageeventsvar.onMountAsyncruns after theheader_extrarequest 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 owncontent_styleapplies over it key by key, and afull_bleedpage keeps zero padding.
A page can also replace the module-wide header blocks with the
header_blocksvar (header_blocks: []for a bare header). -
#303
f7659a7Thanks @SamTolmay! - The notifications inbox is redesigned, and notifications are live.- Inbox. The
allpage 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 newviewsvar 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_typesentry; 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-livewebsocket. Thelayoutpage component subscribes on every page with a signed-in user, unless the page passeshide_notifications: true. Each signed-in user with a page open holds one change stream and one pooled MongoDB connection, so sizemaxPoolSizefor concurrent users. Change streams need a replica set; where they cannot run, set the newlivevar tofalseand the bell and toasts update on navigation. - Toasts. A notification flagged
popup: truetoasts 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 atdata.links.button). The dispatch pipeline storespopupfrom the item, defaultfalse. - 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-pluginsfor theNotificationSoundblock. - A record's type reads
typebeforeevent_typeeverywhere, so a record carrying both is filtered, counted and labelled under its notification id.
- Inbox. The
-
#303
f7659a7Thanks @SamTolmay! - Module links follow Lowdefy page paths andbasePath. No module builds a page URL by hand any more, so nothing breaks when a page has apathor the app has abasePath.- Pages with paths. The layout
pagecomponent takes apathvar, such astickets/{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-idlinks, so they move within the app without a full page load.DataDescriptionsandSmartDescriptionsrender contact and company links with Lowdefy'sLink, andActionSteps,WorkflowProgressandEventsTimelinepass a link'spathParamsto it. The sign-in, sign-up, forgot-password and accept pages link to each other by page id. - Workflows. A workflow's
entity:block takesid_path_key, the entity page's path placeholder for the entity id (for a page atdeals/{deal_id},id_path_key: deal_id); the entity link then carries the id inpathParams.id_query_keydefaults to_idonly whenid_path_keyis not set, so set both to also pass the id in the query.tracker.start_linkand the custom-kindstatus_maplink:/view_link:cells takepathParamsnext tourlQuery, with the sameaction_id: true/entity_id: truesentinels. Breadcrumbs, the return to the entity after a terminal signal, the events timeline and the deal's open items passpathParamstoo. - Breaking (events):
contact_page_urlis replaced bycontact_page_id. Set it to the contact page's id, such ascontacts/view. The contact id goes in the query ascontact_id_query_key(default_id), in the path valuecontact_id_path_keynames when the page has a path likecontacts/{contact_id}, or both. The events-timeline component takes the same three vars per call, and theEventsTimelineblock takescontactPageId,contactIdQueryKeyandcontactIdPathKeyin place ofcontactPageUrl. The timeline's avatar, timestamp and note @mentions link to the contact page;note-capturestores each @mention as a#contact-<id>link, and mentions in notes saved before this show unlinked. - Breaking (ai-reporting):
generate-report,create-reportandduplicate-reportreturn{ ok, report_id }withouturl. Open a report with aLinkto thereportpage andurlQuery: { 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/viewwith the user's id, which is not a contact id.
- Pages with paths. The layout
Patch Changes
-
#277
8ca77a4Thanks @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 usestitleonly when it is a string at build time, and otherwise the page'stype(for example "Company"), or the page id when there is none. The new and edit pages of activities, companies, contacts and deals build theirtypeat build time, so their tab reads, for example, "Edit Company". -
@lowdefy/modules-mongodb-notifications: ### Major Changes
-
#303
f7659a7Thanks @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 upgradeapplies 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_nameis gone. Declareslug:on the root oflowdefy.yamland drop the var from every module entry.user-accountanduser-adminrun on the Better Auth engine, with contacts linked to their auth user and organization authority separate from app roles.- Search:
request_stages.filter_matchtakes plain$matchclauses, the newatlas_searchvar picks Atlas$searchor a regex fallback, and the Atlas Search index contract changes. - Events:
contact_page_urlis replaced bycontact_page_id. - ai-reporting: the app lists the module's
report-policyinpolicies.dynamicBlocks, and the report endpoints returnreport_idwithouturl. profile.pictureis derived by every write, andcreate-contactmints the contact_iditself.
Minor Changes
-
#127
b8a2ef6Thanks @SamTolmay! - Breaking: theapp_namemodule var is replaced by Lowdefy's_appoperator. Apps now declare their identity once, asslug:on the root oflowdefy.yaml, and every module reads it directly with_build.app: slug.Migration for consumers:
- Add
slug: <your-app-slug>to the root oflowdefy.yamlif 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-adminandworkflowsno longer declare it. events.display_keyis 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_stampoverride baked in an app-attribution literal (app_name: my-app), change the value to{ _build.app: slug }. The stamp field keeps the nameapp_name. - Any shared config file that existed only to hold
app_name(e.g. anapp_config.yaml) can be deleted.
No data migration is required — the slug produces the same string the
app_namevar did, and no stored field or key is renamed.created.app_namekeeps its name on event and notification documents.Connection schema changes in
@lowdefy/modules-mongodb-plugins: theWorkflowAPIconnection propertyapp_nameis renamed toslug, and theEventsTimelineconnection propertyapp_nameis renamed todisplay_key(it carries the events module'sdisplay_key, which may legitimately differ from the slug). Apps wiring these connections directly must rename the property keys. - Add
-
#242
9e083dcThanks @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 everyAiOutline*,AiFill*andLu*name in the modules and the plugin blocks is rewritten with the@lowdefy/codemodsv7-0-0/react-icons-to-lucide.jsontable: 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 bothFileTextand its Excel icon isFileSpreadsheet, and the Cluster icon isWaypoints.Apps that pass icon names into module vars (menu entries, enum
iconfields,event_types) need the same rewrite;lowdefy upgradeapplies it. -
#303
f7659a7Thanks @SamTolmay! - The notifications module owns the dispatch pipeline around Lowdefy's notification render. Lowdefy renders notification emails from the app'snotifications:config through theRenderNotificationstep; the module composes the rest. Seedocs/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 dedupkey, so concurrent dispatches cannot double-send), sends, and records the result. A failed send never fails the dispatch: the record stayssent: falsewithsend_attemptsbumped.send_routinekeeps its{ event_ids }contract and typically ends in aCallApitodispatch-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. Underpolicy: tenantit retries each record bound to itsorganization_id, which Lowdefy accepts only in a trusted system run, so call it from a schedule.- Transports.
transport: smtp(default, thenotifications-emailconnection, configured by theemailvars with theNOTIFICATIONS_SMTP_PASSsecret) orsendgrid(notifications-email-sendgrid, thesendgridvars withSENDGRID_API_KEY). Either connection can be remapped to the app's own. - Recipient filters.
email.filterandsendgrid.filtertake{ replaceAddress, allowlist, regex }. Left null they fall back to the current Lowdefy environment'semail.filter, andserver_url(the origin of email links) falls back to the environment'surl, so an app that declaresconfig.environmentscan leave them unset. Types in the newfilter_exempt_typesvar (default: the pre-auth invite types) always reach the real recipient.email_resulton 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 followpathParams.public_link_typesnames the link types thelinkpage resolves without a session. - Public pages. The manifest contributes
link,invalidandfile-downloadtoauth.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 onkey: { $type: 'string' }). Without it the guarantee does not exist. Two read indexes onuser_id/contact_idwithcreated.app_nameandcreated.timestampkeep 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
f7659a7Thanks @SamTolmay! - The notifications inbox is redesigned, and notifications are live.- Inbox. The
allpage 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 newviewsvar 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
- Inbox. The