@imola-solutions/distribution-lists
v0.4.0
Published
Distribution list management + non-confidential message distribution. List CRUD, member management, CSV bulk import, notice send. Consumers inject apiClient + currentUser + optional AI helpers.
Readme
@imola-solutions/distribution-lists
Distribution list management and non-confidential message distribution. Ships list CRUD, member management (with internal/external access rules), CSV bulk import (with optional AI dedup + classification), and a record-type-agnostic "Notice Distribution" section for sending a published notice to a list.
Extracted from BOPC-ITMP. Consumers inject their own apiClient,
currentUser, and optional AI helpers.
Install
npm install @imola-solutions/distribution-lists @imola-solutions/uiPeer deps (already in most React + MUI stacks):
react>=18,react-dom>=18@mui/material>=5@phosphor-icons/react>=2dayjs>=1
Service factory
import { createDistributionListService } from "@imola-solutions/distribution-lists";
const service = createDistributionListService({
apiClient, // Supabase-compatible: .from(table).select/insert/update/upsert/delete
currentUser, // { id, name?, displayName? }
onAudit, // (event) => void — compliance-grade event stream
aiExtensions: { // all optional
suggestRecipients: async (ctx) => [...],
classifyContact: async (row) => "internal" | "vendor" | "citizen" | "duplicate",
dedupContacts: async (rows) => canonicalRows,
},
notifier: { // optional — actually delivers the message
sendNotice: async ({ messageId, recipients, subject, bodyHtml, bodyText, ... }) =>
({ ok: true }),
},
});API surface
| Method | Purpose |
| --- | --- |
| fetchLists({ activeOnly }) | Distribution list catalog |
| createList({ list_name, description, list_type, topic_scope }) | Create a list |
| deleteList(id) | Delete a list |
| fetchListMembers(listId) | Members flattened with contact data |
| fetchContactMemberships(contactId) | Which lists a contact belongs to |
| addMembers({ listId, listType, contacts }) | Upsert contacts + insert memberships |
| removeMembers({ listId, membershipIds }) | Delete membership rows |
| bulkImportCsv({ csvText, listType, existingEmails }) | Parse + validate a CSV |
| commitCsvImport({ listId, listType, mapping, results }) | Persist the parsed CSV |
| distributeNotice({ listId, recordId, recordType, recordTitle, subject, bodyText, bodyHtml, recipients, messagesTable, recipientsTable, extraFields }) | Persist a message + recipient rows, hand off to notifier |
| suggestRecipients(context) | AI extension — throws if not wired |
| hasAi | Capability flags: { suggestRecipients, classifyContact, dedupContacts } |
UI
Members panel (drawer)
<DistributionListMembersPanel
service={service}
listId={list.id}
listName={list.list_name}
listType={list.list_type}
commissioners={commissioners} // optional
onMemberCountChange={(id, n) => ...}
/>Contact's list memberships (drawer)
<ContactListMemberships
service={service}
contactId={contact.id}
userType={contact.user_type}
onMembershipsChange={() => refresh()}
/>Notice distribution — for any record type
NoticeDistributionSection is fully decoupled from meetings. It takes a
generic record via recordId / recordType / recordTitle:
<NoticeDistributionSection
service={service}
recordId={application.id}
recordType="license-application"
recordTitle={application.applicant_name}
defaultSubject={`License decision — ${application.applicant_name}`}
defaultBody={application.decision_notice_text}
isReady={application.status === "approved"}
title="Notify Distribution List"
onSent={(evt) => console.log("sent", evt)}
/>For legacy meeting-notice use, the default messagesTable /
recipientsTable values (meeting_messages + meeting_message_recipients)
preserve compatibility. Override for other record types.
Compliance & audit
Every mutation (list create, member add/remove, CSV import, notice send)
and every AI call emits a structured event through onAudit:
{
action, // 'distribution-list.create' | 'distribution-list.member.add' | ...
actorId, // currentUser.id
targetType, // 'list' | 'membership' | ...
targetId,
timestamp, // Date.now()
metadata, // action-specific
success, // true | false
}Audit callback exceptions are swallowed so a logger outage never breaks the distribution flow.
AI extensions
All three are optional and independent:
suggestRecipients(context)— given{ subject, body, recordId, recordType, availableLists }, suggest which lists or member IDs to select. Rendered as an "AI suggest" button in the notice section when wired.classifyContact(row)— during CSV import, tag rows asinternal | vendor | citizen | duplicate. Displayed as a status chip in the preview table.dedupContacts(rows)— return the canonical subset; non-canonical rows get an error marker before import.
Check service.hasAi.<capability> to conditionally render UI actions.
Stable defaults
Exports EMPTY_ARR and EMPTY_OBJ (frozen module-level singletons) so
consumers don't need to allocate throwaway defaults per render.
