diff --git a/.changeset/activity-model-events-and-recency.md b/.changeset/activity-model-events-and-recency.md new file mode 100644 index 00000000..ed758f6c --- /dev/null +++ b/.changeset/activity-model-events-and-recency.md @@ -0,0 +1,78 @@ +--- +'hotcrm': minor +--- + +Make the CRM able to answer "what happened with this customer, and when?". + +**A meeting is now a record.** The new `crm_event` object holds one row per +interaction that occupies a slot on someone's calendar — subject, start and end, +duration, location, type (meeting / call / demo / webinar / onsite visit), +status, owner, and the same five polymorphic `related_to_*` lookups `crm_task` +carries. It ships with a calendar view, a team-schedule timeline, a personal +calendar, an upcoming queue and an interaction-history list, all reachable from +a new **Activity** section in the navigation. + +**Attendees are records, not a sentence.** `log_meeting` used to write the +attendee list into a JSON string inside `sys_activity.metadata`, where no view +could filter it, no dataset could group by it and no report could count it. The +new `crm_event_attendee` junction stores one row per person — contact, lead, +colleague or named external guest — each with its own response +(accepted / declined / tentative) and organiser flag. "Meetings this rep +attended" and "contacts who declined twice this quarter" are now ordinary +queries. + +**A rep can log a call on anything they sell to.** `log_call`, `log_meeting` +and the new `schedule_meeting` are registered on **lead, contact, account, +opportunity and case** instead of on cases alone, and each one writes a real +`crm_event` plus its attendee rows. The `sys_activity` row survives as the +unified-timeline pointer, now with an ADR-0052 `source_object`/`source_id` drill +to the event itself. `schedule_meeting` books a `planned` event; only a `held` +one counts as contact. + +**Interaction recency finally has a writer — it had none.** `at_risk_accounts` +and `customer_churn_signals` are built entirely on +`crm_account.last_activity_date`, and that column was permanently null, for two +independent reasons that both had to be fixed: + +- The only writer bubbled to the record the task *named*. A rep names the + opportunity or the contact, never the account, so the account's clock never + moved. Both bubbles now walk **up** from a contact, opportunity or case to the + account above it. +- Even a direct write was silently discarded. `last_activity_date` and + `crm_lead.last_contacted_date` were `readonly`, and the engine strips a + readonly key from every non-system write whose caller supplied it (#2948) — a + hook runs as the acting user, so every bubble the app ever performed was + dropped with a warning nobody read. **Migration:** both fields are now + writable metadata rather than readonly; they remain absent from every form + section, so nothing about the editing surface changes. + +`crm_contact` gains `last_contacted_date` for the same reason — the record a rep +actually calls had no recency of its own — and `send_email` now stamps it, along +with the account above the recipient. + +**Activity has numbers for the first time.** A new `event_metrics` dataset +(activities, minutes, average duration, by rep / type / week / related record) +powers a new **Sales Activity** dashboard: interactions logged, meetings booked, +customer minutes, activity by rep, weekly volume, activity mix, interactions on +deals, and accounts quiet for 30 / 60 / 90 days. The dashboard is also the first +consumer of the `task_metrics` dataset, which had shipped with no widget using +it at all. + +**The activity actions are on the record header, not just the row menu.** The +lead, opportunity and account detail pages are custom pages, and a custom page +replaces the synthesized record header — so an object-scoped action it does not +name is unreachable from the record itself. All three now list **Log a Call**, +**Log a Meeting** and **Schedule a Meeting** in their header. (The case page's +action set is a deliberate curation and is unchanged.) + +**Booking a meeting collects a date and a time, not a single "Starts At".** +`schedule_meeting` asks for **Start Date (UTC)** and **Start Time (UTC)** and +joins them into the stored instant. This is a workaround for a platform defect +(objectstack-ai/objectstack#5061): an action param declared `datetime` is +rendered by the Console as a zone-less `datetime-local` input and posted raw, +which the runtime's param validator rejects — so *no* value a user could type +was submittable, and the action failed with a 400 every time. `date` and `time` +are the two param types whose native pickers emit exactly what the validator +accepts. The wall clock is read as UTC, which is why both labels say so; when +the platform fix lands this collapses back to one `datetime` param interpreted +in the user's own timezone. diff --git a/content/docs/administration/sharing-and-security.mdx b/content/docs/administration/sharing-and-security.mdx index 3b06de58..0476b86e 100644 --- a/content/docs/administration/sharing-and-security.mdx +++ b/content/docs/administration/sharing-and-security.mdx @@ -118,8 +118,9 @@ Sharing rules are authored **per object**. Widening `Account` widens accounts | Contracts | Their own only | | Cases | Their own only, plus open critical cases if they hold `service_manager` or `service_director` | | Tasks | Their own only | +| Events | Their own only | -So a territory rule hands over the account record and its contacts; the deal, quote, contract, case and task history underneath stays with its owners, and those related lists can look empty or partial on an account the user can otherwise read in full. +So a territory rule hands over the account record and its contacts; the deal, quote, contract, case, task and meeting history underneath stays with its owners, and those related lists can look empty or partial on an account the user can otherwise read in full. Making a child object follow the account is a deliberate widening, not a config detail: author a sharing rule on the child object with criteria matching the account rules, or set the child's OWD to Controlled by Parent. Either one opens that object for **every** holder of it, not only for the territory recipients — which is why HotCRM ships neither by default. diff --git a/src/actions/contact.actions.ts b/src/actions/contact.actions.ts index 1e1c269e..f2974022 100644 --- a/src/actions/contact.actions.ts +++ b/src/actions/contact.actions.ts @@ -85,9 +85,34 @@ export const SendEmailAction: Action = { source_id: email?.id ?? null, metadata: JSON.stringify({ kind: 'email', to, subject }), }); + // #592 scope item 3: every activity writer bumps interaction recency. + // An email is an interaction but NOT a calendar slot, so it writes no + // \`crm_event\` — which means it cannot ride the event hook's bubble and + // has to stamp the two timestamps itself. Both columns are deliberately + // non-readonly (see crm_account.last_activity_date's note): a readonly + // field is stripped from any non-system write whose caller supplied the + // key (#2948), and an action body runs under the sending rep's context. + if (recipientId) { + const nowIso = new Date().toISOString(); + try { + await ctx.api.object('crm_contact').update( + { id: recipientId, last_contacted_date: nowIso }, + { where: { id: recipientId } }, + ); + } catch (e) { /* best-effort recency; never fail the send */ } + const accountId = record.crm_account ? String(record.crm_account) : null; + if (accountId) { + try { + await ctx.api.object('crm_account').update( + { id: accountId, last_activity_date: nowIso.slice(0, 10) }, + { where: { id: accountId } }, + ); + } catch (e) { /* best-effort recency; never fail the send */ } + } + } return { emailId: email?.id, activityId: activity?.id }; `, - capabilities: ['api.write'], + capabilities: ['api.read', 'api.write'], timeoutMs: 5000, }, locations: ['record_header', 'list_item'], diff --git a/src/actions/global.actions.ts b/src/actions/global.actions.ts index 017bd56b..6ac07434 100644 --- a/src/actions/global.actions.ts +++ b/src/actions/global.actions.ts @@ -3,23 +3,61 @@ import type { Action } from '@objectstack/spec/ui'; import * as objects from '../objects'; +/** + * Activity-logging actions — `log_call`, `log_meeting`, `schedule_meeting`. + * + * (The file is still named `global.actions.ts` for import stability; nothing + * here is a *global* action any more, and nothing can be — see the dispatcher + * note on `objectName` below.) + * + * # What changed in #592 + * + * Two defects, one shape: + * + * 1. **Scope.** Both twins were pinned to `crm_case` as a workaround for the + * upstream key mismatch (#509), so a sales rep could not log a call on a + * lead, a contact, an account or an opportunity — i.e. on anything they + * sell to. They are now GENERATED per object: one registered action per + * (kind × object), which sidesteps #509 without waiting for the platform, + * because the runtime keys the registry on `:` and + * the dispatcher probes `` first. + * 2. **Shape.** The write was a `sys_activity` row whose `metadata` carried a + * JSON blob — `{"kind":"meeting","attendees":"Bob, Alice"}`. That is not + * data: no view filters it, no dataset groups by it, no report counts it. + * Every one of these actions now inserts a real `crm_event`, real + * `crm_event_attendee` rows, and keeps the `sys_activity` row purely as the + * unified-timeline pointer (ADR-0052 `source_object`/`source_id`), the same + * way `send_email` points at its `sys_email`. + * + * # Live platform workaround + * + * `schedule_meeting` collects its start as a `date` + `time` PAIR rather than + * as one `datetime` param, because a `datetime` action param cannot be + * submitted from the Console at all (objectstack-ai/objectstack#5061). Both the + * body and the param list carry the full reasoning and the revert instruction. + * + * The recency bubble (`crm_account.last_activity_date`, + * `crm_lead.last_contacted_date`, `crm_contact.last_contacted_date`) is + * deliberately NOT written here. It hangs off `crm_event`'s own hook, so an + * event created from the calendar, from an import, or from a future flow bumps + * recency exactly like one created from this button. One writer, not one per + * entry point. + */ + /** * `objectName` → the object's DECLARED `nameField`, derived from the object * definitions rather than hand-listed. * - * Issue #514 item 2: the activity writers below stamped - * `record_label: ctx.record?.name`, but `name` is not the display field on - * almost anything here — 14 of the 15 objects declare a different `nameField` - * (`display_title`, `full_name`, `subject`, `contract_number`, …), and most of - * them have no `name` column at all, so the label landed `null`. `crm_case` — - * the object both actions are currently scoped to — is one of those. + * Issue #514 item 2: the activity writers stamped `record_label: ctx.record?.name`, + * but `name` is not the display field on almost anything here — most objects + * declare a different `nameField` (`display_title`, `full_name`, `subject`, + * `contract_number`, …) and have no `name` column at all, so the label landed + * `null`. * - * Deriving the map has two properties a hardcoded read cannot have: it stays - * correct when an object retargets its `nameField`, and it keeps working if - * these actions are restored to the global design described below (where the - * object is only known at call time). Formula `nameField`s resolve fine — the - * data engine materialises formula fields on read, so the loaded `ctx.record` - * carries `display_title` alongside the stored columns. + * Deriving the map keeps it correct when an object retargets its `nameField`. + * Since #592 the lookup happens at AUTHORING time (each action knows its own + * object), so the body carries the resolved field name rather than a table — + * one less thing for a body to get wrong at runtime. */ const NAME_FIELD_BY_OBJECT: Record = Object.fromEntries( Object.values(objects as Record) @@ -31,64 +69,87 @@ const NAME_FIELD_BY_OBJECT: Record = Object.fromEntries( ); /** - * The authored difference between one activity-logging action and the next. + * The objects a rep can log an interaction against, and the `crm_event` + * lookup that records the link. + * + * The keys are exactly `crm_event.related_to_type`'s options + * (`RELATED_TO_TYPE_OPTIONS`), and the values exactly the `related_to_*` + * lookups that back them. `test/activity-actions.test.ts` pins that agreement + * against the object definition, so adding a sixth `related_to_*` field + * without extending this map fails in CI instead of producing an action that + * writes an event linked to nothing. + */ +export const ACTIVITY_TARGETS: Record = { + crm_lead: 'related_to_lead', + crm_contact: 'related_to_contact', + crm_account: 'related_to_account', + crm_opportunity: 'related_to_opportunity', + crm_case: 'related_to_case', +}; + +/** JS string literal, safely quoted for splicing into a body source. */ +const lit = (value: string): string => JSON.stringify(value); + +/** + * The authored difference between one activity action and the next. * * Issue #514 item 15: `log_call` and `log_meeting` were near-verbatim copies — - * identical bodies apart from a summary prefix and a metadata key, identical - * params apart from labels and the meeting's `attendees` — which is how they - * drifted into disagreeing about whether `duration` is required. Everything - * they share now lives in {@link logActivityAction}; this type is the complete - * list of what a twin is still allowed to differ on. + * identical bodies apart from a summary prefix and a metadata key — which is + * how they drifted into disagreeing about whether `duration` is required. + * Everything they share lives in {@link activityAction}; this type is the + * complete list of what a variant is still allowed to differ on. */ -type LogActivitySpec = { +type ActivitySpec = { name: string; label: string; icon: string; + /** `crm_event.type` for the row this action writes. */ + eventType: string; + /** `crm_event.status`: `held` for something that happened, `planned` for a booking. */ + eventStatus: 'held' | 'planned'; /** Stamped in front of the activity summary; `''` for calls. */ summaryPrefix: string; /** Subject used when the user submits the form with the field blank. */ defaultSubject: string; - /** `metadata.kind` discriminator on the `sys_activity` row. */ - kind: string; - /** Extra `metadata` entries as `key` → JS expression source, spliced into the body. */ - metadataExtras: Record; subjectLabel: string; notesLabel: string; - /** Params appended after the shared subject / duration core. */ - extraParams?: NonNullable; + /** Booked-in-the-future variants collect a start date + wall clock and a location. */ + collectsSchedule?: boolean; successMessage: string; }; /** - * Build an activity-logging action from the one shared body + param core. + * Build one activity action, bound to one object. * - * `duration` is OPTIONAL on every twin. It was `required: true` for calls and + * `duration` is OPTIONAL everywhere. It was `required: true` for calls and * `required: false` for meetings with nothing documenting the split; optional - * is the direction that keeps both forms submittable, and it is the one the - * body was already written for — the `duration ? … : subject` summary branch - * is unreachable while the field is mandatory. - * - * The shared body is also where `crm_case.first_response_date` is stamped - * (#575 B2) — because every activity twin routes through here, "the first - * outbound contact on a case" has exactly one implementation instead of one - * per action. + * is the direction that keeps every form submittable, and it is the one the + * body is written for. */ -function logActivityAction(spec: LogActivitySpec): Action { - const extras = Object.entries(spec.metadataExtras) - .map(([key, expr]) => `${key}: ${expr}`) - .join(', '); +function activityAction(spec: ActivitySpec, objectName: string): Action { + const relatedField = ACTIVITY_TARGETS[objectName]; + if (!relatedField) { + throw new Error( + `activityAction: '${objectName}' is not an activity target — extend ACTIVITY_TARGETS ` + + 'together with crm_event.related_to_type and its related_to_* lookup.', + ); + } + // Resolved here, not in the body: the action knows its object at authoring + // time, so the body carries the answer instead of a lookup table it could + // miss on. `?? 'name'` only fires for an object that declares no nameField + // at all, which no business object in this app does. + const nameField = NAME_FIELD_BY_OBJECT[objectName] ?? 'name'; + return { name: spec.name, label: spec.label, - // Scoped to crm_case, no longer global — issue #509. The runtime registers - // a body action without an objectName under the key 'global', but the - // dispatcher only probes '' then '*' — a global body action is - // therefore unreachable from every surface ("Action 'log_call' on object - // '*' not found", verified 2026-07-28). crm_case is where the app wires - // this action (case_detail header); scoped, it registers under crm_case - // and executes. When the upstream key mismatch is fixed, restoring the - // global design is just deleting this objectName. - objectName: 'crm_case', + // Object-scoped, one registration per object (#509 / #592). The runtime + // registers a body action under `:` and the dispatcher + // probes `` then `*`; a body action with no objectName lands + // under a 'global' key nothing ever probes ("Action 'log_call' on object + // '*' not found", verified 2026-07-28). Generating the family is what makes + // the action reachable from every sales object without the platform fix. + objectName, icon: spec.icon, // script, not modal: modal submits die on GET /api/v1/meta/object/ // → 400 in 16.1.0; script actions POST /api/v1/actions/... and execute. @@ -96,52 +157,157 @@ function logActivityAction(spec: LogActivitySpec): Action { body: { language: 'js', source: ` - const NAME_FIELD_BY_OBJECT = ${JSON.stringify(NAME_FIELD_BY_OBJECT)}; + const OBJECT_NAME = ${lit(objectName)}; + const RELATED_FIELD = ${lit(relatedField)}; + const NAME_FIELD = ${lit(nameField)}; + const EVENT_TYPE = ${lit(spec.eventType)}; + const EVENT_STATUS = ${lit(spec.eventStatus)}; + const record = ctx.record ?? {}; const recordId = ctx.recordId ?? record.id ?? null; - // \`ctx.object\` is the name the sandbox context carries; the dispatcher - // also mirrors it into the params as \`objectName\`. Both are checked - // because the label lookup below is only as good as this value. - const objectName = ctx.objectName ?? ctx.object ?? input.objectName ?? null; - const subject = input.subject ? String(input.subject) : '${spec.defaultSubject}'; + const userId = ctx.user?.id ?? null; + const nowIso = new Date().toISOString(); + + const subject = input.subject ? String(input.subject) : ${lit(spec.defaultSubject)}; const duration = input.duration ? Number(input.duration) : 0; const notes = input.notes ? String(input.notes) : ''; - const summary = duration - ? subject + ' (' + duration + ' min)' - : subject; - // #514 item 2: read the object's declared nameField, NOT a hardcoded - // \`.name\` — see NAME_FIELD_BY_OBJECT in src/actions/global.actions.ts. - const nameField = NAME_FIELD_BY_OBJECT[objectName] ?? 'name'; + const location = input.location ? String(input.location) : ''; + + // A booking states its own start; a log is "just now". + // + // WORKAROUND for objectstack-ai/objectstack#5061 — the start is collected + // as a CALENDAR DAY + a WALL CLOCK (\`start_date\` / \`start_time\`) and + // joined here, instead of as one \`type: 'datetime'\` param. A datetime + // param is unusable from the Console: it renders as a zone-less + // \`\` and POSTs the raw value + // ("2026-08-10T15:00"), which the runtime's action-param validator rejects + // with 400 "expected an ISO-8601 instant with explicit zone" — no user + // input can pass. \`date\` and \`time\` are the two param types whose + // renderer output ("YYYY-MM-DD" / "HH:MM") the validator accepts verbatim, + // so the pair is submittable where the single datetime never was. + // + // TIMEZONE: the wall clock is interpreted as **UTC**, and both param + // labels say so. UTC is the only zone this body can apply deterministically + // — the sandbox context carries no user or org timezone (\`ctx.user\` is + // id/name/email), and reading the server's local zone would make the same + // input mean different instants on different hosts and in tests. Everything + // else this app writes is likewise a UTC instant (\`new Date().toISOString()\`). + // + // When #5061 lands (the Console serializing datetime-local in the + // browser's zone), this reverts to ONE \`type: 'datetime'\` param and the + // zone becomes the user's own — see \`collectsSchedule\` in this file. + let startIso = nowIso; + const startDate = input.start_date ? String(input.start_date).trim() : ''; + if (startDate) { + const startTime = input.start_time ? String(input.start_time).trim() : '00:00'; + // 'HH:MM' and 'HH:MM:SS' are both shapes the validator lets through. + const clock = startTime.length === 5 ? startTime + ':00' : startTime; + const parsed = new Date(startDate + 'T' + clock + '.000Z'); + // An unparseable value falls back to now rather than writing NaN into a + // required column. + if (!isNaN(parsed.getTime())) startIso = parsed.toISOString(); + } + + // 1. the interaction itself, as a queryable record. + const eventDoc = { + subject: subject, + type: EVENT_TYPE, + status: EVENT_STATUS, + start_datetime: startIso, + owner: userId, + related_to_type: OBJECT_NAME, + }; + if (duration > 0) eventDoc.duration_minutes = duration; + if (location) eventDoc.location = location; + if (notes) eventDoc.description = notes; + if (recordId) eventDoc[RELATED_FIELD] = recordId; + const event = await ctx.api.object('crm_event').insert(eventDoc); + const eventId = event?.id ?? null; + + // 2. attendees, as rows — the point of #592. + // A multi-lookup param may arrive as an array or, on a surface that + // renders it single-valued, as one scalar. Normalising both is not a + // lenient parse of authored metadata: it is the console's own input + // shape, which this body does not control. + const toList = (v) => { + if (v === null || v === undefined || v === '') return []; + return Array.isArray(v) ? v : [v]; + }; + const attendees = []; + if (userId) { + attendees.push({ attendee_type: 'user', sys_user: userId, is_organizer: true, response: 'accepted' }); + } + // The record the action was fired from IS an attendee when it is a + // person. On an account / opportunity / case it is not, so nothing is + // invented for it — the event's related_to_* link already records it. + if (OBJECT_NAME === 'crm_contact' && recordId) { + attendees.push({ attendee_type: 'contact', crm_contact: recordId, response: 'no_response' }); + } + if (OBJECT_NAME === 'crm_lead' && recordId) { + attendees.push({ attendee_type: 'lead', crm_lead: recordId, response: 'no_response' }); + } + for (const c of toList(input.attendee_contacts)) { + attendees.push({ attendee_type: 'contact', crm_contact: String(c), response: 'no_response' }); + } + for (const u of toList(input.attendee_users)) { + attendees.push({ attendee_type: 'user', sys_user: String(u), response: 'no_response' }); + } + + const seen = {}; + const attendeeIds = []; + for (const a of attendees) { + const who = a.sys_user ?? a.crm_contact ?? a.crm_lead ?? ''; + const key = a.attendee_type + ':' + who; + if (seen[key]) continue; + seen[key] = true; + const doc = Object.assign({}, a, { crm_event: eventId, invited_date: nowIso }); + const row = await ctx.api.object('crm_event_attendee').insert(doc); + if (row?.id) attendeeIds.push(row.id); + } + + // 3. the unified-timeline pointer. + // \`metadata\` no longer carries the attendee list: it is a display hint + // beside a real record now, not the record itself. ADR-0052 + // source_object/source_id is the queryable drill to the crm_event row. + const summary = duration ? subject + ' (' + duration + ' min)' : subject; const activity = await ctx.api.object('sys_activity').insert({ - type: 'completed', - summary: '${spec.summaryPrefix}' + summary, - actor_id: ctx.user?.id ?? null, + type: EVENT_STATUS === 'held' ? 'completed' : 'scheduled', + summary: ${lit(spec.summaryPrefix)} + summary, + actor_id: userId, actor_name: ctx.user?.name ?? null, - object_name: objectName, + object_name: OBJECT_NAME, record_id: recordId, - record_label: record[nameField] ?? null, - metadata: JSON.stringify({ kind: '${spec.kind}', duration_minutes: duration, notes, ${extras} }), + // #514 item 2: the object's DECLARED nameField, not a hardcoded name. + record_label: record[NAME_FIELD] ?? null, + source_object: 'crm_event', + source_id: eventId, + metadata: JSON.stringify({ + kind: EVENT_TYPE, + duration_minutes: duration, + notes: notes, + attendee_count: attendeeIds.length, + }), }); - // SLA first-response stamp (#575 B2). \`first_response_date\` was the one - // member of the case SLA family with no writer at all — \`sla_due_date\` - // and \`resolution_time_hours\` come from case.hook, \`is_sla_violated\` - // from the case_sla_monitor flow — so the metric was permanently null. - // A logged call or meeting is the only record of outbound contact a case - // carries, which makes the FIRST \`sys_activity\` on the case the moment - // the customer first heard back: the industry definition (Salesforce - // \`FirstResponseDateTime\`, Zendesk first reply time). A status change is - // deliberately NOT used — an agent can move a case to "in progress" and - // investigate for an hour while the customer hears nothing. + + // 4. SLA first-response stamp (#575 B2, cases only). + // \`first_response_date\` was the one member of the case SLA family with + // no writer at all, so the metric was permanently null. A logged call or + // meeting is the only record of outbound contact a case carries, which + // makes the FIRST one the moment the customer first heard back — the + // industry definition (Salesforce \`FirstResponseDateTime\`, Zendesk first + // reply time). A status change is deliberately NOT used: an agent can + // move a case to "in progress" and investigate for an hour while the + // customer hears nothing. A meeting merely BOOKED is not a response + // either, which is why this is gated on EVENT_STATUS. // - // CONVENTION: any future customer-facing path on a case (a reply-email - // action, an inbound portal reply) MUST stamp this too, or the metric - // silently under-reports. + // CONVENTION: any future customer-facing path on a case MUST stamp this + // too, or the metric silently under-reports. // - // The stored value is read rather than taken from \`ctx.record\`: the + // The stored value is READ rather than taken from \`ctx.record\`: the // list_item / record_related dispatch paths hand the body a PROJECTED // record, and a field missing from that projection reads as blank — which // would re-stamp on every log and turn "first response" into "last". - if (objectName === 'crm_case' && recordId) { + if (OBJECT_NAME === 'crm_case' && recordId && EVENT_STATUS === 'held') { const raw = await ctx.api.object('crm_case').find({ where: { id: recordId }, fields: ['first_response_date'], @@ -151,16 +317,16 @@ function logActivityAction(spec: LogActivitySpec): Action { const stored = found.length ? found[0].first_response_date : record.first_response_date; if (!stored) { // \`update(data, options)\` — \`ctx.api\` is the engine repo facade, - // whose update takes a DOCUMENT, not an id (mass_update_stage is the - // action that got this wrong; test/action-sandbox.test.ts pins the - // contract against a real kernel). + // whose update takes a DOCUMENT, not an id (test/action-sandbox.test.ts + // pins the contract against a real kernel). await ctx.api.object('crm_case').update( - { id: recordId, first_response_date: new Date().toISOString() }, + { id: recordId, first_response_date: nowIso }, { where: { id: recordId } }, ); } } - return { activityId: activity?.id }; + + return { eventId: eventId, activityId: activity?.id ?? null, attendeeIds: attendeeIds }; `, capabilities: ['api.read', 'api.write'], timeoutMs: 5000, @@ -173,13 +339,64 @@ function logActivityAction(spec: LogActivitySpec): Action { type: 'text', required: true, }, + // WORKAROUND objectstack-ai/objectstack#5061 — one `type: 'datetime'` + // param ("Starts At") is what this WANTS to be, and it is unusable: the + // Console renders it as a zone-less `` and + // POSTs the raw value, which the action-param validator rejects with a + // 400 (`expected an ISO-8601 instant with explicit zone`). The renderer's + // output shape and the validator's accepted shape do not intersect, so + // NO user input can submit the action — reproduced from both the list-row + // menu and the record header (dogfood record on hotcrm#670). + // + // `date` and `time` are the two param types where they DO intersect: the + // Console renders native `` / `` + // pickers (`ui-components` DateField / TimeField, both emitting + // `e.target.value` verbatim) and the validator's `CalendarDateValueSchema` + // /`ClockTimeValueSchema` accept exactly `YYYY-MM-DD` and `HH:MM`. Two + // native pickers are also better UX than one free-text box, which is the + // other shape that would have submitted. + // + // The zone is stated in the LABELS, not in `helpText`: the Console's + // action-param form forwards only name/label/type/required/placeholder/ + // options/multiple/accept/maxSize to the field widget, so a `helpText` + // here would never reach the user. + // + // REVERT WHEN #5061 LANDS: back to + // { name: 'start', label: 'Starts At', type: 'datetime', required: true } + // and drop the join in the body above. + ...(spec.collectsSchedule + ? ([ + { name: 'start_date', label: 'Start Date (UTC)', type: 'date', required: true }, + { name: 'start_time', label: 'Start Time (UTC)', type: 'time', required: true }, + { name: 'location', label: 'Location', type: 'text', required: false }, + ] as NonNullable) + : []), { name: 'duration', label: 'Duration (minutes)', type: 'number', required: false, }, - ...(spec.extraParams ?? []), + // The queryable replacement for the old free-text `attendees` string. + // Two params, not one, because the platform has no polymorphic picker — + // and two typed lookups produce typed rows, where one text box produced + // a comma-separated sentence. + { + name: 'attendee_contacts', + label: 'Contact Attendees', + type: 'lookup', + reference: 'crm_contact', + multiple: true, + required: false, + }, + { + name: 'attendee_users', + label: 'Internal Attendees', + type: 'lookup', + reference: 'sys_user', + multiple: true, + required: false, + }, { name: 'notes', label: spec.notesLabel, @@ -192,54 +409,113 @@ function logActivityAction(spec: LogActivitySpec): Action { }; } -/** - * Log a Call. - * - * Collects subject / duration / notes then writes a `sys_activity` record via - * the metadata body. The originating record id is forwarded as `record_id`, - * and the record's display name as `record_label`. - */ -export const LogCallAction: Action = logActivityAction({ +const LOG_CALL_SPEC: ActivitySpec = { name: 'log_call', label: 'Log a Call', icon: 'phone', + eventType: 'call', + eventStatus: 'held', summaryPrefix: '', defaultSubject: 'Untitled Call', - kind: 'call', - metadataExtras: { direction: `'outbound'` }, subjectLabel: 'Call Subject', notesLabel: 'Call Notes', successMessage: 'Call logged successfully!', -}); +}; -/** - * Log a Meeting. - * - * Companion to `log_call`: same `sys_activity` write, plus an `attendees` - * param, so the meeting lands on the record's unified timeline. - */ -export const LogMeetingAction: Action = logActivityAction({ +const LOG_MEETING_SPEC: ActivitySpec = { name: 'log_meeting', label: 'Log a Meeting', - icon: 'calendar', + icon: 'calendar-check', + eventType: 'meeting', + eventStatus: 'held', summaryPrefix: 'Meeting: ', defaultSubject: 'Untitled Meeting', - kind: 'meeting', - metadataExtras: { attendees: `input.attendees ? String(input.attendees) : ''` }, subjectLabel: 'Meeting Subject', notesLabel: 'Meeting Notes', - extraParams: [ - { - name: 'attendees', - label: 'Attendees', - type: 'text', - required: false, - }, - ], successMessage: 'Meeting logged successfully!', -}); +}; + +const SCHEDULE_MEETING_SPEC: ActivitySpec = { + name: 'schedule_meeting', + label: 'Schedule a Meeting', + icon: 'calendar-plus', + eventType: 'meeting', + // `planned`, and that is the whole difference that matters: a booking must + // NOT reset the customer's recency clock (see event.hook.ts). + eventStatus: 'planned', + summaryPrefix: 'Meeting scheduled: ', + defaultSubject: 'Untitled Meeting', + subjectLabel: 'Meeting Subject', + notesLabel: 'Agenda', + collectsSchedule: true, + successMessage: 'Meeting scheduled!', +}; + +/** The three activity kinds, in the order they appear on a record header. */ +export const ACTIVITY_SPEC_NAMES = ['log_call', 'log_meeting', 'schedule_meeting'] as const; + +const ACTIVITY_SPECS: ActivitySpec[] = [LOG_CALL_SPEC, LOG_MEETING_SPEC, SCHEDULE_MEETING_SPEC]; + +/** + * Every activity action, one per (kind × object). + * + * Exported as a flat list so `src/actions/index.ts` can spread it into the + * stack without naming fifteen constants — and so a new activity target is one + * entry in {@link ACTIVITY_TARGETS} rather than three new exports. + */ +export const ActivityActions: Action[] = Object.keys(ACTIVITY_TARGETS).flatMap((objectName) => + ACTIVITY_SPECS.map((spec) => activityAction(spec, objectName)), +); + +/** + * One generated action, by object and kind. + * + * The named exports below go through this rather than re-running the factory, + * so a page that imports `LogCallAction` and the stack that registers + * `ActivityActions` hold the SAME object. Two structurally-identical actions + * under one `:` registry key is a duplicate registration the + * runtime warns about and one of them silently loses. + */ +const activityActionFor = (objectName: string, name: string): Action => { + const found = ActivityActions.find((a) => a.objectName === objectName && a.name === name); + if (!found) throw new Error(`no ${objectName}-scoped '${name}' action was generated`); + return found; +}; + +/* + * The family, named. `src/actions/index.ts` re-exports exactly these — the + * stack is built from `Object.values(actions)`, so every export it forwards + * must be one Action, never a list of them. + * + * Spelled out rather than looped for grep-ability: "which surface can log a + * call on a lead?" has to be answerable with one search, and AGENTS.md trades + * verbosity for exactly that everywhere else too. + */ +export const LeadLogCallAction: Action = activityActionFor('crm_lead', 'log_call'); +export const LeadLogMeetingAction: Action = activityActionFor('crm_lead', 'log_meeting'); +export const LeadScheduleMeetingAction: Action = activityActionFor('crm_lead', 'schedule_meeting'); + +export const ContactLogCallAction: Action = activityActionFor('crm_contact', 'log_call'); +export const ContactLogMeetingAction: Action = activityActionFor('crm_contact', 'log_meeting'); +export const ContactScheduleMeetingAction: Action = activityActionFor('crm_contact', 'schedule_meeting'); + +export const AccountLogCallAction: Action = activityActionFor('crm_account', 'log_call'); +export const AccountLogMeetingAction: Action = activityActionFor('crm_account', 'log_meeting'); +export const AccountScheduleMeetingAction: Action = activityActionFor('crm_account', 'schedule_meeting'); + +export const OpportunityLogCallAction: Action = activityActionFor('crm_opportunity', 'log_call'); +export const OpportunityLogMeetingAction: Action = activityActionFor('crm_opportunity', 'log_meeting'); +export const OpportunityScheduleMeetingAction: Action = activityActionFor('crm_opportunity', 'schedule_meeting'); + +/** + * The `crm_case`-scoped twins keep their original export names, because + * `src/pages/case_detail.page.ts` imports them by those names. + */ +export const LogCallAction: Action = activityActionFor('crm_case', 'log_call'); +export const LogMeetingAction: Action = activityActionFor('crm_case', 'log_meeting'); +export const CaseScheduleMeetingAction: Action = activityActionFor('crm_case', 'schedule_meeting'); // ExportToCsvAction was removed: as a global body action it registered under -// the 'global' key the dispatcher never probes (same defect as log_call above), +// the 'global' key the dispatcher never probes (same defect as #509 above), // and the list grids' built-in `exportOptions: ['csv', 'xlsx']` already cover // CSV export without any action. diff --git a/src/actions/index.ts b/src/actions/index.ts index 929b0fba..0f2c8028 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -14,6 +14,17 @@ export { EnrollLeadsAction } from './campaign.actions'; export { EscalateCaseAction, CloseCaseAction } from './case.actions'; export { MarkPrimaryContactAction, SendEmailAction } from './contact.actions'; -export { LogCallAction, LogMeetingAction } from './global.actions'; +// Activity logging, one registration per (kind × object) — #592. Every export +// forwarded from here must be ONE Action: the stack is built from +// `Object.values(actions)`, so an exported array would arrive as a nested list +// and fail the schema parse. `ActivityActions` (the flat list the factory +// produces) is therefore deliberately NOT re-exported. +export { + LogCallAction, LogMeetingAction, CaseScheduleMeetingAction, + LeadLogCallAction, LeadLogMeetingAction, LeadScheduleMeetingAction, + ContactLogCallAction, ContactLogMeetingAction, ContactScheduleMeetingAction, + AccountLogCallAction, AccountLogMeetingAction, AccountScheduleMeetingAction, + OpportunityLogCallAction, OpportunityLogMeetingAction, OpportunityScheduleMeetingAction, +} from './global.actions'; export { ConvertLeadAction, CreateCampaignAction, ScheduleFollowUpAction } from './lead.actions'; export { CloneOpportunityAction, MassUpdateStageAction, GenerateQuoteAction } from './opportunity.actions'; diff --git a/src/apps/crm.app.ts b/src/apps/crm.app.ts index 103fa428..7c8692fb 100644 --- a/src/apps/crm.app.ts +++ b/src/apps/crm.app.ts @@ -85,10 +85,34 @@ export const CrmApp = App.create({ { id: 'nav_my_deals', type: 'object', objectName: 'crm_opportunity', viewName: 'my_open_deals', label: 'My Deals', icon: 'target' }, { id: 'nav_my_leads', type: 'object', objectName: 'crm_lead', viewName: 'my_leads', label: 'My Leads', icon: 'user-plus' }, { id: 'nav_my_cases', type: 'object', objectName: 'crm_case', viewName: 'my_open_cases', label: 'My Cases', icon: 'life-buoy' }, + // #592 — the rep's own calendar. Same reasoning as `nav_my_tasks`: a + // ListView is the only surface where "mine" actually means mine + // (`{current_user_id}` interpolates on the list-view data path and + // nowhere else), so the personal calendar is a view, not a dashboard. + { id: 'nav_my_calendar', type: 'object', objectName: 'crm_event', viewName: 'my_events', label: 'My Calendar', icon: 'calendar-days' }, { id: 'nav_all_tasks', type: 'object', objectName: 'crm_task', label: 'All Tasks', icon: 'list' }, ], }, + { + // #592 — activity was the app's largest blind spot: `crm_event` and its + // attendee rows had nowhere to be seen, and no dashboard anywhere counted + // an interaction. Kept as its own group rather than buried under Sales, + // because "what happened with this customer, and when?" is the question + // the whole batch was about. + id: 'group_activity', + type: 'group', + label: 'Activity', + icon: 'calendar-days', + expanded: true, + children: [ + { id: 'nav_event', type: 'object', objectName: 'crm_event', label: 'Events', icon: 'calendar-days' }, + { id: 'nav_event_calendar', type: 'object', objectName: 'crm_event', viewName: 'event_calendar', label: 'Calendar', icon: 'calendar' }, + { id: 'nav_event_history', type: 'object', objectName: 'crm_event', viewName: 'held_events', label: 'Interaction History', icon: 'history' }, + { id: 'nav_activity_dashboard', type: 'dashboard', dashboardName: 'sales_activity_dashboard', label: 'Sales Activity', icon: 'activity' }, + ], + }, + { // Campaigns drive lead_source and the campaign-member records that // "Add to Campaign" writes — with no nav entry the marketing half of diff --git a/src/dashboards/activity.dashboard.ts b/src/dashboards/activity.dashboard.ts new file mode 100644 index 00000000..afaf79de --- /dev/null +++ b/src/dashboards/activity.dashboard.ts @@ -0,0 +1,259 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Dashboard } from '@objectstack/spec/ui'; + +/** + * Sales Activity & Customer Health Dashboard (#592). + * + * The app had **no activity metric of any kind**: calls per rep, meetings + * booked and activity per deal were unanswerable because the only trace of an + * interaction was a `sys_activity` row with the interesting part inside a JSON + * string, and `task_metrics` had shipped with no widget using it at all. This + * dashboard is what `crm_event` makes possible, and it is where the churn story + * finally has numbers behind it. + * + * # No `dateRange`, deliberately + * + * `crm_event.start_datetime` is a `Field.datetime()`, which is the exact column + * shape that zeroed the Service dashboard (#460): `driver-sql` coerces a + * datetime filter bound to epoch-millisecond INTEGER while every datetime in + * the database is ISO TEXT, and SQLite orders every INTEGER before every TEXT — + * so `$gte` matches every row (no floor) and `$lte` matches none (empty + * dashboard). Upstream #3912 lands in the 17.0 train, but #3777 — a bare + * `YYYY-MM-DD` upper bound silently dropping everything created after + * midnight — is a separate, still-open defect. Until both are settled and + * browser-verified, a date picker on this dashboard would be a control that + * lies. + * + * The time dimension is served instead by the `start_datetime` dataset + * dimension, bucketed to `week` (@objectstack 17 honours a bucketed + * dimension), which puts the trend on an AXIS rather than in a filter. The + * churn tiles do window a date, but on `crm_account.last_activity_date` — a + * `Field.date()`, TEXT `YYYY-MM-DD` on both sides of the comparison, which is + * why the same windows already work in `customer_churn_signals`. + * + * No KPI tile declares a `trend`: a period-over-period delta is a measurement + * and has to come from a real comparison query, not from a number typed by + * hand (#500, #587). + */ +export const ActivityDashboard: Dashboard = { + name: 'sales_activity_dashboard', + label: 'Sales Activity', + description: 'Who is talking to customers, how often, and which accounts have gone quiet', + + columns: 12, + gap: 4, + refreshInterval: 300, + + header: { + showTitle: true, + showDescription: true, + }, + + globalFilters: [ + { + field: 'owner', + label: 'Rep', + type: 'lookup', + scope: 'dashboard', + optionsFrom: { object: 'sys_user', valueField: 'id', labelField: 'name' }, + }, + ], + + widgets: [ + // ─── Row 1: what happened, and what is booked ───────────────────── + { + id: 'interactions_held', + title: 'Interactions Logged', + description: 'Calls and meetings that actually happened', + type: 'metric', + filter: { status: 'held' }, + colorVariant: 'success', + dataset: 'event_metrics', values: ['event_count'], + layout: { x: 0, y: 0, w: 3, h: 2 }, + options: { icon: 'PhoneCall', format: '0,0' }, + }, + { + id: 'meetings_booked', + title: 'Meetings Booked', + description: 'Meetings on the calendar that have not happened yet', + type: 'metric', + filter: { status: 'planned', type: 'meeting' }, + colorVariant: 'blue', + dataset: 'event_metrics', values: ['event_count'], + layout: { x: 3, y: 0, w: 3, h: 2 }, + options: { icon: 'CalendarPlus', format: '0,0' }, + }, + { + id: 'customer_minutes', + title: 'Customer Minutes', + description: 'Total time spent in front of customers', + type: 'metric', + filter: { status: 'held' }, + colorVariant: 'purple', + dataset: 'event_metrics', values: ['total_minutes'], + layout: { x: 6, y: 0, w: 3, h: 2 }, + options: { icon: 'Clock', format: '0,0' }, + }, + { + // First widget in the app to use `task_metrics`. The dataset shipped with + // the "My Day" work and then had no consumer at all (#592) — a semantic + // layer nobody queries is a maintenance cost with no reader. + id: 'tasks_completed', + title: 'Tasks Completed', + description: 'Follow-ups closed out — the other half of activity', + type: 'metric', + filter: { is_completed: true }, + colorVariant: 'orange', + dataset: 'task_metrics', values: ['task_count'], + layout: { x: 9, y: 0, w: 3, h: 2 }, + options: { icon: 'CheckCheck', format: '0,0' }, + }, + + // ─── Row 2: who, and when ───────────────────────────────────────── + { + id: 'activity_by_rep', + title: 'Activity by Rep', + description: 'Logged interactions per owner — the coverage question', + type: 'bar', + filter: { status: 'held' }, + colorVariant: 'success', + dataset: 'event_metrics', dimensions: ['owner'], values: ['event_count'], + layout: { x: 0, y: 2, w: 6, h: 4 }, + chartConfig: { + type: 'bar', + showLegend: false, + showDataLabels: true, + colors: ['#10B981'], + xAxis: { field: 'owner', title: 'Rep', showGridLines: false, logarithmic: false }, + yAxis: [{ field: 'event_count', title: 'Interactions', showGridLines: true, logarithmic: false }], + }, + }, + { + id: 'activity_by_week', + title: 'Activity Volume by Week', + description: 'Interactions per week — is the team speeding up or going quiet?', + type: 'area', + filter: { status: 'held' }, + colorVariant: 'blue', + // The week bucket lives on the DIMENSION, not in a filter — see the + // header note on why a datetime filter bound cannot be trusted yet. + dataset: 'event_metrics', dimensions: ['start_datetime'], values: ['event_count'], + layout: { x: 6, y: 2, w: 6, h: 4 }, + chartConfig: { + type: 'area', + showLegend: false, + showDataLabels: false, + colors: ['#0EA5E9'], + xAxis: { field: 'start_datetime', title: 'Week', showGridLines: false, logarithmic: false }, + yAxis: [{ field: 'event_count', title: 'Interactions', showGridLines: true, logarithmic: false }], + interaction: { tooltips: true, brush: true }, + }, + }, + + // ─── Row 3: what the activity is about ──────────────────────────── + { + id: 'activity_mix', + title: 'Activity Mix', + description: 'Calls vs meetings vs demos', + type: 'donut', + filter: { status: 'held' }, + colorVariant: 'purple', + dataset: 'event_metrics', dimensions: ['type'], values: ['event_count'], + layout: { x: 0, y: 6, w: 4, h: 4 }, + chartConfig: { + type: 'donut', + showLegend: true, + showDataLabels: true, + colors: ['#4169E1', '#10B981', '#8B5CF6', '#F59E0B', '#0EA5E9', '#94A3B8'], + }, + }, + { + id: 'activity_by_record_type', + title: 'Where the Activity Lands', + description: 'Which part of the funnel is getting attention', + type: 'bar', + filter: { status: 'held' }, + colorVariant: 'blue', + dataset: 'event_metrics', dimensions: ['related_to_type'], values: ['event_count'], + layout: { x: 4, y: 6, w: 4, h: 4 }, + chartConfig: { + type: 'bar', + showLegend: false, + showDataLabels: true, + colors: ['#4169E1'], + xAxis: { field: 'related_to_type', title: 'Related To', showGridLines: false, logarithmic: false }, + yAxis: [{ field: 'event_count', title: 'Interactions', showGridLines: true, logarithmic: false }], + }, + }, + { + // "Activity per open deal" as two honest numbers rather than one + // fabricated ratio: the semantic layer has no cross-dataset calculated + // measure, so a single "1.8 activities/deal" tile would have to be + // computed by the renderer over two independent queries — which nothing + // does today. A manager reads the pair; a made-up quotient would read as + // measured. Filed as a follow-up rather than faked here. + id: 'deal_activity', + title: 'Interactions on Deals', + description: 'Logged interactions linked to an opportunity — read against Open Deals →', + type: 'metric', + filter: { status: 'held', related_to_type: 'crm_opportunity' }, + colorVariant: 'success', + dataset: 'event_metrics', values: ['event_count'], + layout: { x: 8, y: 6, w: 2, h: 4 }, + options: { icon: 'Target', format: '0,0' }, + }, + { + id: 'open_deals_for_activity', + title: 'Open Deals', + description: 'Opportunities still in play', + type: 'metric', + filter: { stage: { $nin: ['closed_won', 'closed_lost'] } }, + colorVariant: 'blue', + dataset: 'opportunity_metrics', values: ['opp_count'], + layout: { x: 10, y: 6, w: 2, h: 4 }, + options: { icon: 'Briefcase', format: '0,0' }, + }, + + // ─── Row 4: the churn story, now backed by a real signal ────────── + // These window `crm_account.last_activity_date`, which is a `Field.date()` + // (TEXT `YYYY-MM-DD`) — the same shape `customer_churn_signals` already + // filters safely. Until #592 the column was written by nothing at all: the + // bubble that fed it was silently discarded on every invocation because + // the field was `readonly` (#2948). These three tiles are the reason that + // mattered. + { + id: 'quiet_accounts_30', + title: 'Quiet 30+ Days', + description: 'Active accounts with no logged interaction in a month', + type: 'metric', + filter: { is_active: true, last_activity_date: { $lt: '{30_days_ago}' } }, + colorVariant: 'warning', + dataset: 'account_metrics', values: ['account_count'], + layout: { x: 0, y: 10, w: 4, h: 2 }, + options: { icon: 'BellOff', format: '0,0' }, + }, + { + id: 'quiet_accounts_60', + title: 'Quiet 60+ Days', + description: 'Two months of silence — the at-risk threshold', + type: 'metric', + filter: { is_active: true, last_activity_date: { $lt: '{60_days_ago}' } }, + colorVariant: 'orange', + dataset: 'account_metrics', values: ['account_count'], + layout: { x: 4, y: 10, w: 4, h: 2 }, + options: { icon: 'AlertTriangle', format: '0,0' }, + }, + { + id: 'quiet_accounts_90', + title: 'Quiet 90+ Days', + description: 'A quarter with no contact — intervene or write it off', + type: 'metric', + filter: { is_active: true, last_activity_date: { $lt: '{90_days_ago}' } }, + colorVariant: 'danger', + dataset: 'account_metrics', values: ['account_count'], + layout: { x: 8, y: 10, w: 4, h: 2 }, + options: { icon: 'AlertOctagon', format: '0,0' }, + }, + ], +}; diff --git a/src/dashboards/index.ts b/src/dashboards/index.ts index 86d811f5..4330e59b 100644 --- a/src/dashboards/index.ts +++ b/src/dashboards/index.ts @@ -3,6 +3,7 @@ /** * Dashboard Definitions Barrel */ +export { ActivityDashboard } from './activity.dashboard'; export { CrmOverviewDashboard } from './crm.dashboard'; export { ExecutiveDashboard } from './executive.dashboard'; export { SalesDashboard } from './sales.dashboard'; diff --git a/src/datasets/event.dataset.ts b/src/datasets/event.dataset.ts new file mode 100644 index 00000000..0aff6ff2 --- /dev/null +++ b/src/datasets/event.dataset.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +/** + * Event analytics dataset (ADR-0021) — the semantic layer #592 needed. + * + * Before this, the app had NO activity metric of any kind: calls per rep, + * meetings booked and activity per deal were all unanswerable, because the + * only trace of an interaction was a `sys_activity` row with the interesting + * part inside a JSON string. `crm_event` is a real object, so it gets a real + * cube. + * + * `related_to_type` is a dimension and the `related_to_*` lookups are not: + * grouping by a lookup id yields a column per raw id, which reads as noise on + * a chart. "Which deals are starved of activity" is answered by filtering to + * `related_to_type = crm_opportunity` and counting, not by grouping on the id. + * + * `start_datetime` declares `dateGranularity: 'week'`: every widget built on + * it asks "how much activity per week", and on @objectstack 17 a bucketed + * dimension is honoured (see `test/dataset-granularity.test.ts` for the 16.x + * history and why the buckets could not be declared before). + */ +export const EventDataset = defineDataset({ + name: 'event_metrics', + label: 'Activity Metrics', + description: 'Semantic layer for meetings, calls and interaction recency', + object: 'crm_event', + dimensions: [ + { name: 'type', label: 'Activity Type', field: 'type', type: 'string' }, + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'owner', label: 'Owner', field: 'owner', type: 'string' }, + { name: 'related_to_type', label: 'Related To', field: 'related_to_type', type: 'string' }, + { + name: 'start_datetime', + label: 'Activity Week', + field: 'start_datetime', + type: 'date', + dateGranularity: 'week', + }, + ], + measures: [ + { name: 'event_count', label: 'Activities', aggregate: 'count' }, + { + name: 'total_minutes', + label: 'Minutes', + aggregate: 'sum', + field: 'duration_minutes', + format: '0,0', + }, + { + name: 'avg_minutes', + label: 'Avg Duration', + aggregate: 'avg', + field: 'duration_minutes', + format: '0,0', + }, + ], +}); diff --git a/src/datasets/index.ts b/src/datasets/index.ts index d6576bc2..0d0a454f 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -8,4 +8,5 @@ export { AccountDataset } from './account.dataset'; export { ContactDataset } from './contact.dataset'; export { LeadDataset } from './lead.dataset'; export { TaskDataset } from './task.dataset'; +export { EventDataset } from './event.dataset'; export { ForecastDataset } from './forecast.dataset'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 01dcf76c..efb097d6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -16,6 +16,7 @@ import campaignHook from '../objects/campaign.hook'; import caseHook from '../objects/case.hook'; import contactHook from '../objects/contact.hook'; import contractHook from '../objects/contract.hook'; +import eventHook from '../objects/event.hook'; import forecastHook from '../objects/forecast.hook'; import knowledgeArticleHook from '../objects/knowledge_article.hook'; import leadHook from '../objects/lead.hook'; @@ -32,6 +33,7 @@ const entries: Array = [ caseHook, contactHook, contractHook, + eventHook, forecastHook, knowledgeArticleHook, leadHook, diff --git a/src/objects/_picklists.ts b/src/objects/_picklists.ts index 4b96aefe..63c8ae41 100644 --- a/src/objects/_picklists.ts +++ b/src/objects/_picklists.ts @@ -118,6 +118,71 @@ export const OPPORTUNITY_STAGE_OPTIONS: SelectOption[] = [ { label: 'Closed Lost', value: 'closed_lost', color: '#FF0000' }, ]; +/** + * Event Type — crm_event.type, and the `kind` discriminator the per-object + * activity actions stamp on the `crm_event` row they insert (#592). + * + * Distinct from {@link TASK_TYPE_OPTIONS} on purpose: a Task is something a rep + * still owes someone, an Event is an interaction that occupies a slot on the + * calendar. `email` is therefore absent here (an email is not a meeting slot; + * it stays a `sys_email` + `sys_activity` pair) and `webinar` / `onsite_visit` + * are present, because those are the meeting shapes a rep books. + */ +export const EVENT_TYPE_OPTIONS: SelectOption[] = [ + { label: 'Meeting', value: 'meeting', color: '#4169E1', default: true }, + { label: 'Call', value: 'call', color: '#00AA00' }, + { label: 'Demo', value: 'demo', color: '#9370DB' }, + { label: 'Webinar', value: 'webinar', color: '#FFA500' }, + { label: 'Onsite Visit', value: 'onsite_visit', color: '#0EA5E9' }, + { label: 'Other', value: 'other', color: '#808080' }, +]; + +/** + * Event Status — crm_event.status. + * + * The `planned` → `held` transition is what separates "a meeting is booked" + * from "an interaction happened", and only the second one bumps + * `crm_account.last_activity_date` (see `event.hook.ts`). Without that split a + * meeting booked for next quarter would reset the churn clock today. + */ +export const EVENT_STATUS_OPTIONS: SelectOption[] = [ + { label: 'Planned', value: 'planned', color: '#4169E1', default: true }, + { label: 'Held', value: 'held', color: '#00AA00' }, + { label: 'Cancelled', value: 'cancelled', color: '#999999' }, + { label: 'No Show', value: 'no_show', color: '#FF4500' }, +]; + +/** + * Attendee Response — crm_event_attendee.response. + * + * The reason attendees are RECORDS rather than a JSON string on the activity + * (#592 acceptance): a per-attendee response only exists if the attendee does. + */ +export const ATTENDEE_RESPONSE_OPTIONS: SelectOption[] = [ + { label: 'No Response', value: 'no_response', color: '#808080', default: true }, + { label: 'Accepted', value: 'accepted', color: '#00AA00' }, + { label: 'Declined', value: 'declined', color: '#FF0000' }, + { label: 'Tentative', value: 'tentative', color: '#FFA500' }, +]; + +/** + * Polymorphic "Related To" type — crm_task.related_to_type and + * crm_event.related_to_type. + * + * Both activity objects carry the same five `related_to_*` lookups, and both + * hooks bubble recency through the same `related_to_type → lookup field` map. + * Declared once so the vocabulary of the discriminator cannot drift from the + * set of lookups that back it — a drift that reads as "the bubble silently + * stopped firing for one object type". + */ +export const RELATED_TO_TYPE_OPTIONS: SelectOption[] = [ + { label: 'Account', value: 'crm_account' }, + { label: 'Contact', value: 'crm_contact' }, + { label: 'Opportunity', value: 'crm_opportunity' }, + { label: 'Lead', value: 'crm_lead' }, + { label: 'Case', value: 'crm_case' }, +]; + /** Project a canonical set down to bare `{ label, value }` pairs for flow * screens and action params, which don't understand color/default keys. */ export const plainOptions = (options: SelectOption[]): { label: string; value: string }[] => diff --git a/src/objects/account.object.ts b/src/objects/account.object.ts index 401cdc46..658f4361 100644 --- a/src/objects/account.object.ts +++ b/src/objects/account.object.ts @@ -283,9 +283,28 @@ export const Account = ObjectSchema.create({ }), // Date field + // + // NOT `readonly` (#592). This is the signal `at_risk_accounts` and + // `customer_churn_signals` are built on, and it is written by ONE path: + // another object's hook calling + // `api.object('crm_account').update({ last_activity_date }, …)`. + // + // That write was being thrown away on every invocation. `stripReadonlyFields` + // deletes a readonly key from any payload whose CALLER supplied it, for every + // context that is not `isSystem` (#2948) — and a hook's `ctx.api` is a + // `ScopedContext` over the *acting user's* execution context, not a system + // one. So the engine logged `Field 'last_activity_date' is read-only — + // ignoring incoming change` and moved on; the column stayed null for the + // life of the app, and the churn report has been counting every account as + // silent since the day it was written. + // + // Same reasoning, same fix as `crm_campaign_member.added_date` and + // `crm_case.is_sla_violated`: a field a hook or flow must write cannot be + // `readonly`. It stays out of every form section instead, which is the + // protection that actually holds. `test/activity-recency.test.ts` proves + // the write lands — and fails if the flag comes back. last_activity_date: Field.date({ label: 'Last Activity Date', - readonly: true, group: 'system', }), diff --git a/src/objects/contact.object.ts b/src/objects/contact.object.ts index f97c176b..f592a4e4 100644 --- a/src/objects/contact.object.ts +++ b/src/objects/contact.object.ts @@ -178,6 +178,22 @@ export const Contact = ObjectSchema.create({ defaultValue: false, group: 'preferences', }), + + // Interaction recency for the PERSON (#592). `crm_account` carries + // `last_activity_date` for the company and `crm_lead` carries + // `last_contacted_date` for a prospect; the contact — the record a rep + // actually calls and emails — had neither, so "when did anyone last speak + // to our champion?" was unanswerable. + // + // Written by the activity bubble in `event.hook.ts` / `task.hook.ts` and by + // `send_email`. Deliberately NOT `readonly`: a readonly field is stripped + // from every non-system write whose caller supplied the key (#2948), which + // is exactly how the account and lead columns above ended up permanently + // null. It is kept off the form sections instead. + last_contacted_date: Field.datetime({ + label: 'Last Contacted', + group: 'additional', + }), }, // Enable features diff --git a/src/objects/event.hook.ts b/src/objects/event.hook.ts new file mode 100644 index 00000000..e7492b2d --- /dev/null +++ b/src/objects/event.hook.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Hook, HookContext } from '@objectstack/spec/data'; +import type { HookApi } from './_hook-api'; + +/** + * Event lifecycle hooks (#592). + * + * - `event_schedule_derive` keeps `start_datetime` / `end_datetime` / + * `duration_minutes` mutually consistent, so a calendar view and a duration + * report cannot disagree about the same row. + * - `event_activity_bubble` is the app's PRIMARY writer of interaction + * recency: a `held` event stamps `crm_account.last_activity_date`, + * `crm_lead.last_contacted_date` and `crm_contact.last_contacted_date`, + * walking UP from an opportunity / case / contact to the account it hangs + * off. + * + * # Why the walk-up matters + * + * `at_risk_accounts` and `customer_churn_signals` are built on + * `crm_account.last_activity_date`, but a rep logs a call on the OPPORTUNITY + * or the CONTACT — almost never on the account row itself. Bubbling only to + * the directly-named record (what `task_activity_bubble` used to do) therefore + * left the account clock untouched through an entire sales cycle, and the + * churn report counted a busy customer as silent. The walk-up is the whole + * reason the signal becomes real. + */ + +const eventScheduleDerive: Hook = { + name: 'event_schedule_derive', + object: 'crm_event', + events: ['beforeInsert', 'beforeUpdate'], + priority: 200, + description: + 'Keep start/end/duration coherent: derive whichever of end_datetime or duration_minutes was not supplied.', + handler: async (ctx: HookContext) => { + const { input } = ctx; + const previous = ctx.previous; + + const effective = (key: string): unknown => + input[key] !== undefined && input[key] !== null ? input[key] : previous?.[key]; + + const start = effective('start_datetime'); + const end = effective('end_datetime'); + const durationRaw = effective('duration_minutes'); + + const startMs = typeof start === 'string' || start instanceof Date ? new Date(start as string).getTime() : NaN; + const endMs = typeof end === 'string' || end instanceof Date ? new Date(end as string).getTime() : NaN; + const duration = typeof durationRaw === 'number' && isFinite(durationRaw) ? durationRaw : NaN; + + // An all-day event has no meaningful minute count; leave duration alone so + // a report can tell "all day" apart from "we forgot to fill it in". + const allDay = effective('all_day') === true; + + if (!isNaN(startMs) && !isNaN(endMs)) { + // Both ends known — the duration is a MEASUREMENT, so it is recomputed + // even when the caller supplied one. A stored duration that disagrees + // with its own timestamps is the kind of metadata a report quietly + // averages into nonsense. + if (!allDay) input.duration_minutes = Math.max(0, Math.round((endMs - startMs) / 60000)); + } else if (!isNaN(startMs) && isNaN(endMs) && !isNaN(duration) && duration > 0) { + // Duration-only, the shape `log_call` submits ("a 20-minute call, now"). + // Materialising the end timestamp is what puts the row on a calendar. + input.end_datetime = new Date(startMs + duration * 60000).toISOString(); + } + + // A cancelled or no-show meeting occupies no one's time. Zeroing it keeps + // "meeting minutes this week" honest without deleting the row, which is + // still evidence that the interaction was attempted. + const status = effective('status'); + if (status === 'cancelled' || status === 'no_show') input.duration_minutes = 0; + }, +}; + +const eventActivityBubble: Hook = { + name: 'event_activity_bubble', + object: 'crm_event', + events: ['afterInsert', 'afterUpdate'], + priority: 800, + async: true, + onError: 'log', + description: + 'A held event stamps interaction recency on the related account (walking up from contact/opportunity/case), lead and contact.', + handler: async (ctx: HookContext) => { + const { input } = ctx; + const previous = ctx.previous; + const api = ctx.api as HookApi | undefined; + if (!api) return; + + const r: Record = { ...(previous ?? {}), ...input }; + + // Only an interaction that HAPPENED resets the recency clock. A meeting + // booked for next quarter is not contact; letting `planned` bubble would + // make an account look freshly-touched the moment someone put a placeholder + // on the calendar, which is the exact failure mode the churn report exists + // to avoid. + if (r.status !== 'held') return; + // Fire once, on the transition into `held` (afterInsert has no `previous`). + if (previous && previous.status === 'held') return; + + const nowIso = new Date().toISOString(); + // `crm_account.last_activity_date` is a DATE column; the two contact + // timestamps are datetimes. Passing an ISO instant to a date column is what + // stores '2026-08-04T…' in a field every filter compares as 'YYYY-MM-DD'. + const today = nowIso.slice(0, 10); + + const idOf = (key: string): string | undefined => + typeof r[key] === 'string' && r[key].length > 0 ? (r[key] as string) : undefined; + + const accountIds = new Set(); + const contactIds = new Set(); + const leadIds = new Set(); + + const direct = idOf('related_to_account'); + if (direct) accountIds.add(direct); + const contactId = idOf('related_to_contact'); + if (contactId) contactIds.add(contactId); + const leadId = idOf('related_to_lead'); + if (leadId) leadIds.add(leadId); + + // Walk UP to the account. Every one of these objects names its parent + // `crm_account`, so one loop covers all three. `find(... top: 1)` rather + // than `findOne`: the two agree here, and `find` is the shape the rest of + // the app's hooks read with. + const parentLookups: Array<[string, string]> = [ + ['related_to_contact', 'crm_contact'], + ['related_to_opportunity', 'crm_opportunity'], + ['related_to_case', 'crm_case'], + ]; + for (const [field, object] of parentLookups) { + const id = idOf(field); + if (!id) continue; + try { + const raw: any = await api.object(object).find({ + where: { id }, + fields: ['crm_account'], + top: 1, + }); + const rows = Array.isArray(raw) ? raw : (raw?.records ?? []); + const parent = rows.length ? rows[0].crm_account : undefined; + if (typeof parent === 'string' && parent.length > 0) accountIds.add(parent); + } catch { + // Best-effort: a rep who cannot read the parent record simply does not + // bubble through it. No `console` in the L2 hook sandbox — logging here + // would throw its own ReferenceError (cf. #471). + } + } + + const writes: Array<{ object: string; id: string; doc: Record }> = [ + ...[...accountIds].map((id) => ({ object: 'crm_account', id, doc: { last_activity_date: today } })), + ...[...contactIds].map((id) => ({ object: 'crm_contact', id, doc: { last_contacted_date: nowIso } })), + ...[...leadIds].map((id) => ({ object: 'crm_lead', id, doc: { last_contacted_date: nowIso } })), + ]; + + for (const w of writes) { + try { + // `update(document, options)` — `ctx.api` is the engine repo facade, + // whose update takes a DOCUMENT, not an id (#616; pinned by + // test/hook-write-shape.test.ts against a real kernel). + await api.object(w.object).update({ ...w.doc, id: w.id }, { where: { id: w.id } }); + } catch { + // Best-effort activity bubble; never break the parent write. + } + } + }, +}; + +export default [eventScheduleDerive, eventActivityBubble]; diff --git a/src/objects/event.object.ts b/src/objects/event.object.ts new file mode 100644 index 00000000..7b293a8f --- /dev/null +++ b/src/objects/event.object.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { P, cel } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { + EVENT_STATUS_OPTIONS, + EVENT_TYPE_OPTIONS, + RELATED_TO_TYPE_OPTIONS, +} from './_picklists'; + +/** + * Event — a first-class interaction record (#592). + * + * # Why this object exists + * + * The CRM could not answer *"what happened with this customer, and when?"*. + * `crm_task.type` offered a `meeting` value with no start time, no end time, + * no location and no attendees, and `log_meeting` stuffed the attendee list + * into a JSON string inside `sys_activity.metadata` — a payload no list view + * can filter, no dataset can group by, and no report can count. + * + * `crm_event` is the queryable half of that story: one row per interaction + * that occupies a slot on someone's calendar, whether it already happened (a + * logged call) or is booked for next Tuesday (a scheduled meeting). + * + * # The Task / Event split + * + * `crm_task` stays what it was — something a rep still *owes* someone, due on + * a date. `crm_event` is something that *occurs*, between two timestamps, with + * people in the room. The two objects deliberately do NOT merge: a to-do list + * sorted by due date and a calendar scaled by duration are different surfaces, + * and every CRM that collapsed them ended up with a "duration" column that is + * null on 90% of rows. + * + * Emails are the third shape and stay out: an email is not a calendar slot, so + * `send_email` keeps writing `sys_email` + `sys_activity` (it does now also + * stamp contact recency — see `contact.actions.ts`). + * + * # `status` is what makes the churn signal honest + * + * Only a `held` event bumps `crm_account.last_activity_date` / + * `crm_lead.last_contacted_date` (see `event.hook.ts`). A meeting *booked* for + * next quarter is not an interaction that happened, and letting it reset the + * recency clock is precisely how an "at risk" report learns to lie. + */ +export const Event = ObjectSchema.create({ + name: 'crm_event', + label: 'Event', + pluralLabel: 'Events', + icon: 'calendar-days', + description: 'Meetings, calls and other scheduled interactions with customers', + + // ADR-0090 D1/D7: OWD is an authored decision. Mirrors `crm_task`: an event + // is a personal activity record, owned by the rep who ran it. + sharingModel: 'private', + + fieldGroups: [ + { key: 'basic', label: 'Event Information', icon: 'info' }, + { key: 'schedule', label: 'Schedule', icon: 'calendar' }, + { key: 'related', label: 'Related Records', icon: 'link' }, + { key: 'outcome', label: 'Outcome', icon: 'clipboard-check', defaultExpanded: false }, + ], + + fields: { + // ─── Event Information ──────────────────────────────────────────── + subject: Field.text({ + group: 'basic', + label: 'Subject', + required: true, + storage: { notNull: true }, + searchable: true, + maxLength: 255, + }), + + type: Field.select({ + group: 'basic', + label: 'Event Type', + required: true, + storage: { notNull: true }, + // Field-level default, not just the option flag: the option `default` + // only preselects in some form surfaces, so a quick-create opened from a + // related list left this required field blank (same defect crm_task.status + // carries a note about). + defaultValue: 'meeting', + options: [...EVENT_TYPE_OPTIONS], + }), + + status: Field.select({ + group: 'basic', + label: 'Status', + required: true, + storage: { notNull: true }, + trackHistory: true, + defaultValue: 'planned', + options: [...EVENT_STATUS_OPTIONS], + }), + + description: Field.markdown({ + group: 'basic', + label: 'Description', + }), + + // `owner` is an app-authored `sys_user` lookup, matching `crm_task.owner` + // exactly. The platform `owner_id` migration (#548, Option B) is decided + // but not implemented; authoring the same shape as every sibling object + // means that sweep converts this object with the rest instead of leaving + // one hand-rolled exception behind. + owner: Field.lookup('sys_user', { + group: 'basic', + label: 'Assigned To', + defaultValue: cel`os.user.id`, + trackHistory: true, + }), + + // ─── Schedule ───────────────────────────────────────────────────── + start_datetime: Field.datetime({ + group: 'schedule', + label: 'Start', + required: true, + storage: { notNull: true }, + }), + + end_datetime: Field.datetime({ + group: 'schedule', + label: 'End', + }), + + all_day: Field.boolean({ + group: 'schedule', + label: 'All Day Event', + defaultValue: false, + }), + + // Derived in `event.hook.ts` from start/end. NOT `readonly`: the field is + // also what the activity actions supply directly when a rep logs a call + // that took 20 minutes, and a readonly field is stripped from any write + // whose CALLER supplied the key (#2948) — including an action body running + // under the rep's own context. Derivation inside this object's own + // before-hook is unaffected either way (the strip keys off the caller's + // payload, not the post-hook document), so leaving it writable costs + // nothing and keeps `log_call` honest. + duration_minutes: Field.number({ + group: 'schedule', + label: 'Duration (minutes)', + min: 0, + scale: 0, + }), + + location: Field.text({ + group: 'schedule', + label: 'Location', + maxLength: 255, + description: 'Room, address, or meeting link', + }), + + // ─── Related Records (polymorphic, same shape as crm_task) ───────── + related_to_type: Field.select({ + group: 'related', + label: 'Related To Type', + options: [...RELATED_TO_TYPE_OPTIONS], + }), + + related_to_account: Field.lookup('crm_account', { + group: 'related', + label: 'Related Account', + }), + + related_to_contact: Field.lookup('crm_contact', { + group: 'related', + label: 'Related Contact', + }), + + related_to_opportunity: Field.lookup('crm_opportunity', { + group: 'related', + label: 'Related Opportunity', + }), + + related_to_lead: Field.lookup('crm_lead', { + group: 'related', + label: 'Related Lead', + }), + + related_to_case: Field.lookup('crm_case', { + group: 'related', + label: 'Related Case', + }), + + // ─── Outcome ────────────────────────────────────────────────────── + outcome_notes: Field.markdown({ + group: 'outcome', + label: 'Outcome Notes', + description: 'What was agreed, and what happens next', + }), + + // No `is_past` / `is_upcoming` boolean. A write-time snapshot of "has this + // happened yet" is stale the moment the clock passes it, and the app has + // already been burnt by exactly that (`crm_task.is_overdue` — see the + // `overdue_tasks` view's label note). `status` is the authored, non-decaying + // signal, and the upcoming/past views filter on it and sort by + // `start_datetime` instead. + }, + + // Dead object-level enable.* flags removed in @objectstack 12 (ADR-0049); + // only the live API surface remains. History → Field.trackHistory (ADR-0052). + // + // No `enable.files`: attachments are a REVIEWED set (see the canonical note + // in `src/objects/index.ts` and the ledger in + // `test/collaboration-capabilities.test.ts`), and `crm_event` follows + // `crm_task` — an activity record, not a document home. Meeting decks belong + // to the account or the opportunity the meeting was about. Enabling it here + // is a decision to take on its own, not a rider on this object's creation. + enable: { + apiEnabled: true, + }, + + indexes: [ + { fields: ['start_datetime'] }, + { fields: ['owner'] }, + { fields: ['status'] }, + { fields: ['related_to_account'] }, + { fields: ['related_to_opportunity'] }, + ], + + // ADR-0079: `nameField` names the real field holding the record title. + nameField: 'subject', + highlightFields: ['subject', 'type', 'status', 'start_datetime', 'owner'], + searchableFields: ['subject', 'location'], + + // Predicates below are TOTAL: every `record.x` read is `has()`-guarded, so the + // rule returns a verdict even when the merged record has no such key. See + // AGENTS.md "Validation predicates must be TOTAL" and + // test/object-validation-predicates.test.ts, which fails the build otherwise. + validations: [ + { + name: 'end_after_start', + type: 'script', + severity: 'error', + message: 'The end time must be after the start time', + // BOTH guards, on both operands, because they answer different hazards + // (AGENTS.md "Validation predicates must be TOTAL"): + // · `has(...)` — absent key. On a driver that stores only the columns + // a row was written with, an absent `end_datetime` aborts the whole + // predicate under strict CEL, and from 17.0.0-rc.2 that abort REJECTS + // the save rather than skipping the rule (#4649). + // · `!= null` — present-but-null. `dyn < dyn` aborts too, and + // `has()` says nothing about it. + // An ordering comparison needs both; neither substitutes for the other. + condition: P`has(record.start_datetime) && has(record.end_datetime) && record.start_datetime != null && record.end_datetime != null && !isBlank(record.end_datetime) && record.end_datetime < record.start_datetime`, + }, + { + name: 'related_to_required', + type: 'script', + severity: 'warning', + message: 'At least one related record should be selected', + condition: P`(!has(record.related_to_account) || isBlank(record.related_to_account)) && (!has(record.related_to_contact) || isBlank(record.related_to_contact)) && (!has(record.related_to_opportunity) || isBlank(record.related_to_opportunity)) && (!has(record.related_to_lead) || isBlank(record.related_to_lead)) && (!has(record.related_to_case) || isBlank(record.related_to_case))`, + }, + ], +}); diff --git a/src/objects/event_attendee.object.ts b/src/objects/event_attendee.object.ts new file mode 100644 index 00000000..cf882a79 --- /dev/null +++ b/src/objects/event_attendee.object.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { ATTENDEE_RESPONSE_OPTIONS } from './_picklists'; + +/** + * Event Attendee — who was in the room (#592). + * + * # Why a junction object, and not multi-value lookups + * + * The acceptance criterion is "attendees are queryable RECORDS, not JSON + * strings", and three properties decide the shape: + * + * 1. **Attendees are heterogeneous.** A customer meeting has internal people + * (`sys_user`), existing customers (`crm_contact`) and prospects + * (`crm_lead`) in it. A `Field.lookup(..., { multiple: true })` points at + * exactly ONE object, so a multi-lookup design needs three parallel + * multi-lookups and no way to order or de-duplicate across them. + * 2. **An attendee carries its own attributes.** `response` (accepted / + * declined / tentative) and `is_organizer` belong to the *pairing* of a + * person and an event, not to either side. A multi-value lookup stores a + * bare id array with nowhere to hang them, which is how the old design + * ended up smuggling the whole list into a JSON string in the first place. + * 3. **A junction is queryable the way the issue asks for.** "Meetings this + * rep attended", "contacts who declined twice this quarter" and "accounts + * whose champion has not attended anything in 90 days" are all `find()` + * calls on this object. Against an id array inside a multi-value column + * they are not expressible in ObjectQL at all. + * + * This mirrors `crm_campaign_member`, the app's existing junction, down to the + * autonumber `nameField` and the `controlled_by_parent` OWD. + * + * # Access derives from the event + * + * `sharingModel: 'controlled_by_parent'` (ADR-0055): reads are filtered to + * attendees whose `crm_event` the caller can read, and adding or updating an + * attendee requires edit access to that event. The relation resolver accepts + * the REQUIRED `crm_event` lookup as the parent, so no master-detail + * conversion is needed — same construction as `crm_campaign_member`. An + * attendee row is therefore never more visible than the meeting it belongs to. + */ +export const EventAttendee = ObjectSchema.create({ + name: 'crm_event_attendee', + label: 'Event Attendee', + pluralLabel: 'Event Attendees', + icon: 'users', + description: 'A person invited to or present at an event', + + sharingModel: 'controlled_by_parent', + + // ADR-0079: junction rows have no derivable text title; point the canonical + // nameField at the stored autonumber explicitly (autonumber is not in the + // auto-derivation whitelist). + nameField: 'attendee_number', + + highlightFields: ['crm_event', 'attendee_type', 'response', 'is_organizer'], + + fieldGroups: [ + { key: 'basic', label: 'Attendee', icon: 'user' }, + { key: 'response', label: 'Invitation', icon: 'mail-check' }, + ], + + fields: { + attendee_number: Field.autonumber({ + group: 'basic', + label: 'Attendee Number', + format: 'EA-{00000}', + }), + + crm_event: Field.lookup('crm_event', { + group: 'basic', + label: 'Event', + required: true, + storage: { notNull: true }, + }), + + // The discriminator says which of the three person lookups below is the + // live one. It is authored rather than derived so a query can filter + // "internal attendees only" without three OR'd null checks. + attendee_type: Field.select({ + group: 'basic', + label: 'Attendee Type', + required: true, + storage: { notNull: true }, + defaultValue: 'contact', + options: [ + { label: 'Contact', value: 'contact', color: '#4169E1', default: true }, + { label: 'Lead', value: 'lead', color: '#FFA500' }, + { label: 'User', value: 'user', color: '#00AA00' }, + ], + }), + + crm_contact: Field.lookup('crm_contact', { + group: 'basic', + label: 'Contact', + description: 'Set when the attendee is an existing customer contact', + }), + + crm_lead: Field.lookup('crm_lead', { + group: 'basic', + label: 'Lead', + description: 'Set when the attendee is still an unconverted lead', + }), + + sys_user: Field.lookup('sys_user', { + group: 'basic', + label: 'User', + description: 'Set when the attendee is a colleague', + }), + + // Free text is the LAST resort, not the default: it exists only for the + // genuinely unmodelled guest (a prospect's lawyer who is in no CRM object), + // and the `attendee_resolves` rule below makes it insufficient on its own + // for the three modelled types. It is not a place to paste a list. + external_name: Field.text({ + group: 'basic', + label: 'External Attendee', + maxLength: 255, + description: 'Name of an attendee who is not a CRM record', + }), + + response: Field.select({ + group: 'response', + label: 'Response', + required: true, + storage: { notNull: true }, + trackHistory: true, + defaultValue: 'no_response', + options: [...ATTENDEE_RESPONSE_OPTIONS], + }), + + is_organizer: Field.boolean({ + group: 'response', + label: 'Organizer', + defaultValue: false, + }), + + // NOT `readonly`: written by the activity actions on insert, and 16.x/17.x + // strip a readonly key the CALLER supplied (#2948) — the same reason + // `crm_campaign_member.added_date` is open. + invited_date: Field.datetime({ + group: 'response', + label: 'Invited', + }), + }, + + indexes: [ + { fields: ['crm_event'] }, + { fields: ['crm_contact'] }, + { fields: ['sys_user'] }, + ], + + // Predicates below are TOTAL: every `record.x` read is `has()`-guarded, so the + // rule returns a verdict even when the merged record has no such key. See + // AGENTS.md "Validation predicates must be TOTAL" and + // test/object-validation-predicates.test.ts, which fails the build otherwise. + validations: [ + { + name: 'attendee_resolves', + type: 'script', + severity: 'error', + message: + 'An attendee must point at a Contact, a Lead, a User, or name an external guest', + condition: P`(!has(record.crm_contact) || isBlank(record.crm_contact)) && (!has(record.crm_lead) || isBlank(record.crm_lead)) && (!has(record.sys_user) || isBlank(record.sys_user)) && (!has(record.external_name) || isBlank(record.external_name))`, + }, + ], +}); diff --git a/src/objects/index.ts b/src/objects/index.ts index b3330f7d..5cb96dc9 100644 --- a/src/objects/index.ts +++ b/src/objects/index.ts @@ -61,6 +61,8 @@ export { CampaignMember } from './campaign_member.object'; export { Case } from './case.object'; export { Contact } from './contact.object'; export { Contract } from './contract.object'; +export { Event } from './event.object'; +export { EventAttendee } from './event_attendee.object'; export { Forecast } from './forecast.object'; export { KnowledgeArticle } from './knowledge_article.object'; export { Lead } from './lead.object'; diff --git a/src/objects/lead.object.ts b/src/objects/lead.object.ts index 823677eb..e4b3f999 100644 --- a/src/objects/lead.object.ts +++ b/src/objects/lead.object.ts @@ -298,9 +298,13 @@ export const Lead = ObjectSchema.create({ group: 'qualification', }), + // NOT `readonly` (#592) — see the long note on + // `crm_account.last_activity_date`. The activity bubble writes this from + // another object's hook, and a readonly field is stripped from any + // non-system write whose caller supplied the key (#2948), so every bubble + // into this column was silently discarded. last_contacted_date: Field.datetime({ label: 'Last Contacted', - readonly: true, group: 'qualification', }), diff --git a/src/objects/task.hook.ts b/src/objects/task.hook.ts index 4a051c0d..ede29ee9 100644 --- a/src/objects/task.hook.ts +++ b/src/objects/task.hook.ts @@ -215,52 +215,96 @@ const taskBubble: Hook = { priority: 800, async: true, onError: 'log', - description: 'Bubble last_activity_date to the polymorphic parent record.', + description: + 'A completed task stamps interaction recency on the related account (walking up from contact/opportunity/case), lead and contact.', handler: async (ctx: HookContext) => { + /* + * The activity bubble, second copy. `src/objects/event.hook.ts` carries the + * canonical one and the rationale; this is a deliberate verbatim duplicate, + * not drift — an L2 hook body ships body-only into the QuickJS sandbox, so a + * shared module helper resolves at authoring time and arrives `undefined` at + * runtime (same constraint as `_line-item-price-fill.ts` and the + * `priority_rank` table above). `test/activity-recency.test.ts` runs BOTH + * copies through the same cases so they cannot diverge silently. + * + * Two things changed here versus the version this replaces (#592): + * + * 1. It no longer keys off `related_to_type`. That discriminator is a + * display hint a rep can leave blank — and when they did, a task with a + * perfectly good `related_to_account` bubbled to nothing at all. + * 2. It WALKS UP to the account. A rep completes a task on the + * opportunity, not on the account row, so bubbling only to the named + * record left `crm_account.last_activity_date` untouched through an + * entire deal — which is why `at_risk_accounts` listed active + * customers. + */ const { input } = ctx; const previous = ctx.previous; const api = ctx.api as HookApi | undefined; if (!api) return; - const today = new Date().toISOString().slice(0, 10); - const targetType = - (typeof input.related_to_type === 'string' && input.related_to_type) || - (typeof previous?.related_to_type === 'string' && (previous.related_to_type as string)) || - undefined; - if (!targetType) return; + // Recency means the interaction HAPPENED. An open task is a promise, not + // contact; only the completing transition bubbles. + const nowDone = input.status === 'completed' || input.is_completed === true; + const wasDone = previous?.status === 'completed' || previous?.is_completed === true; + if (!nowDone || wasDone) return; - const fieldByType: Record = { - crm_account: 'related_to_account', - crm_contact: 'related_to_contact', - crm_opportunity: 'related_to_opportunity', - crm_lead: 'related_to_lead', - crm_case: 'related_to_case', - }; - const refField = fieldByType[targetType]; - if (!refField) return; - const targetId = - (typeof input[refField] === 'string' && (input[refField] as string)) || - (typeof previous?.[refField] === 'string' && (previous[refField] as string)) || - undefined; - if (!targetId) return; + const r: Record = { ...(previous ?? {}), ...input }; - // Only bubble to objects that carry an activity timestamp, and use each - // object's own field: `crm_lead` has no `last_activity_date` — its activity - // signal is `last_contacted_date` (a datetime, so pass full ISO). - const activityWriteByType: Record> = { - crm_account: { last_activity_date: today }, - crm_lead: { last_contacted_date: new Date().toISOString() }, - }; - const activityWrite = activityWriteByType[targetType]; - if (!activityWrite) return; - try { - await api.object(targetType).update( - { ...activityWrite, id: targetId }, - { where: { id: targetId } }, - ); - } catch { - // Best-effort activity bubble; never break the parent write. No `console` - // in the L2 hook sandbox (would throw ReferenceError — cf. #471). + const nowIso = new Date().toISOString(); + // `crm_account.last_activity_date` is a DATE column; the two contact + // timestamps are datetimes. + const today = nowIso.slice(0, 10); + + const idOf = (key: string): string | undefined => + typeof r[key] === 'string' && r[key].length > 0 ? (r[key] as string) : undefined; + + const accountIds = new Set(); + const contactIds = new Set(); + const leadIds = new Set(); + + const direct = idOf('related_to_account'); + if (direct) accountIds.add(direct); + const contactId = idOf('related_to_contact'); + if (contactId) contactIds.add(contactId); + const leadId = idOf('related_to_lead'); + if (leadId) leadIds.add(leadId); + + const parentLookups: Array<[string, string]> = [ + ['related_to_contact', 'crm_contact'], + ['related_to_opportunity', 'crm_opportunity'], + ['related_to_case', 'crm_case'], + ]; + for (const [field, object] of parentLookups) { + const id = idOf(field); + if (!id) continue; + try { + const raw: any = await api.object(object).find({ + where: { id }, + fields: ['crm_account'], + top: 1, + }); + const rows = Array.isArray(raw) ? raw : (raw?.records ?? []); + const parent = rows.length ? rows[0].crm_account : undefined; + if (typeof parent === 'string' && parent.length > 0) accountIds.add(parent); + } catch { + // Best-effort: a rep who cannot read the parent simply does not bubble + // through it. No `console` in the L2 hook sandbox (cf. #471). + } + } + + const writes: Array<{ object: string; id: string; doc: Record }> = [ + ...[...accountIds].map((id) => ({ object: 'crm_account', id, doc: { last_activity_date: today } })), + ...[...contactIds].map((id) => ({ object: 'crm_contact', id, doc: { last_contacted_date: nowIso } })), + ...[...leadIds].map((id) => ({ object: 'crm_lead', id, doc: { last_contacted_date: nowIso } })), + ]; + + for (const w of writes) { + try { + await api.object(w.object).update({ ...w.doc, id: w.id }, { where: { id: w.id } }); + } catch { + // Best-effort activity bubble; never break the parent write. + } } }, }; diff --git a/src/pages/account_detail.page.ts b/src/pages/account_detail.page.ts index 677e4cf2..74245043 100644 --- a/src/pages/account_detail.page.ts +++ b/src/pages/account_detail.page.ts @@ -1,6 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Page } from '@objectstack/spec/ui'; +import { + AccountLogCallAction, + AccountLogMeetingAction, + AccountScheduleMeetingAction, +} from '../actions/global.actions'; /** * Account Detail — slotted record page. @@ -48,6 +53,11 @@ export const AccountDetailPage = { eyebrow: 'ACCOUNT', icon: 'building-2', breadcrumb: true, + // Overriding the `header` slot REPLACES the synthesized header, actions + // and all — so this slot has to re-state every action it wants to keep. + // Before #592 it named none, which is why an account record showed no + // activity buttons at all while the list row's ⋮ menu showed three. + actions: [AccountLogCallAction, AccountLogMeetingAction, AccountScheduleMeetingAction], }, }, diff --git a/src/pages/lead_detail.page.ts b/src/pages/lead_detail.page.ts index 3b33ea84..72c60344 100644 --- a/src/pages/lead_detail.page.ts +++ b/src/pages/lead_detail.page.ts @@ -2,6 +2,11 @@ import { Page } from '@objectstack/spec/ui'; import { ConvertLeadAction, ScheduleFollowUpAction } from '../actions/lead.actions'; +import { + LeadLogCallAction, + LeadLogMeetingAction, + LeadScheduleMeetingAction, +} from '../actions/global.actions'; /** * Lead Detail Record Page @@ -64,7 +69,20 @@ export const LeadDetailPage: Page = { // Convert is the outcome; scheduling the next touch is the daily // act. Both belong in the header — the follow-up used to be four // clicks deep in the Related tab. - actions: [ConvertLeadAction, ScheduleFollowUpAction], + // + // The three activity actions are listed EXPLICITLY (#592): a custom + // record page replaces the synthesized header, so an object-scoped + // action that is not named here is unreachable from the record — + // only the list-row ⋮ menu can fire it. Logging the call you just + // made is the single most frequent thing a rep does on a lead, and + // it was two navigations away. + actions: [ + ConvertLeadAction, + ScheduleFollowUpAction, + LeadLogCallAction, + LeadLogMeetingAction, + LeadScheduleMeetingAction, + ], }, }, // Salesforce-style Highlights Panel: a horizontal strip of the diff --git a/src/pages/opportunity_detail.page.ts b/src/pages/opportunity_detail.page.ts index dda621d8..7a46d4d0 100644 --- a/src/pages/opportunity_detail.page.ts +++ b/src/pages/opportunity_detail.page.ts @@ -2,6 +2,11 @@ import { Page } from '@objectstack/spec/ui'; import { CloneOpportunityAction, GenerateQuoteAction } from '../actions/opportunity.actions'; +import { + OpportunityLogCallAction, + OpportunityLogMeetingAction, + OpportunityScheduleMeetingAction, +} from '../actions/global.actions'; /** * Opportunity Detail Record Page @@ -44,8 +49,17 @@ export const OpportunityDetailPage: Page = { breadcrumb: true, // generate_quote is the CPQ entry point (opportunity → quote); a // custom record page replaces the default header, so the action - // must be listed here explicitly or it is unreachable. - actions: [GenerateQuoteAction, CloneOpportunityAction], + // must be listed here explicitly or it is unreachable. The same + // sentence is why the three activity actions are named here (#592): + // without them a rep can log a call on the deal only from the list + // row's ⋮ menu, never from the deal itself. + actions: [ + GenerateQuoteAction, + CloneOpportunityAction, + OpportunityLogCallAction, + OpportunityLogMeetingAction, + OpportunityScheduleMeetingAction, + ], }, }, { diff --git a/src/profiles/sales-manager.profile.ts b/src/profiles/sales-manager.profile.ts index e9ab32c0..efc327f1 100644 --- a/src/profiles/sales-manager.profile.ts +++ b/src/profiles/sales-manager.profile.ts @@ -23,6 +23,11 @@ export const SalesManagerProfile = { crm_campaign: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, crm_case: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false, allowExport: true }, crm_task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + // A manager coaches on activity, so this is org-wide read AND write on a + // private object — the same shape their `crm_task` grant already has. + // `crm_event_attendee` derives from the event (ADR-0055): no scope to author. + crm_event: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + crm_event_attendee: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: false, modifyAllRecords: false }, // The forecast IS the manager's job: they read every rep's snapshot and // adjust the committed number, so this is org-wide read AND write on a // private object (#488 — the object had no grant at all). diff --git a/src/profiles/sales-rep.profile.ts b/src/profiles/sales-rep.profile.ts index 7c53484b..a0fa922d 100644 --- a/src/profiles/sales-rep.profile.ts +++ b/src/profiles/sales-rep.profile.ts @@ -39,6 +39,16 @@ export const SalesRepProfile = { crm_campaign: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, crm_case: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const, allowExport: true }, crm_task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const }, + + // ─── Activity (#592) ────────────────────────────────────────────── + // `crm_event` is `private` like `crm_task` — a personal activity record — + // so an explicit record scope is required alongside allowRead (an omitted + // scope silently means "own only" and reads as an unmade decision). + // `crm_event_attendee` is `controlled_by_parent`: rows follow the event and + // writes require edit on it (ADR-0055), so authoring a readScope here would + // be inert metadata the engine never applies. + crm_event: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const }, + crm_event_attendee: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: false, modifyAllRecords: false }, // Reference catalog (public_read OWD): reps read knowledge articles, which // are authored by service. crm_knowledge_article: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, diff --git a/src/profiles/service-agent.profile.ts b/src/profiles/service-agent.profile.ts index 3a050899..fba9cf3f 100644 --- a/src/profiles/service-agent.profile.ts +++ b/src/profiles/service-agent.profile.ts @@ -24,6 +24,11 @@ export const ServiceAgentProfile = { // sees only their own tasks on it (#549). crm_case: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const, allowExport: true }, crm_task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const }, + // #592 — `log_call` / `log_meeting` are scoped to `crm_case` too, and an + // agent who cannot INSERT a `crm_event` gets a button that 403s. Same + // own-scoped shape as their tasks. + crm_event: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const }, + crm_event_attendee: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: false, modifyAllRecords: false }, crm_product: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, // The knowledge base is this team's own surface: agents draft and revise // articles (draft → in_review → published is enforced by the KB flow, not by diff --git a/src/profiles/system-admin.profile.ts b/src/profiles/system-admin.profile.ts index c87abea8..14862c03 100644 --- a/src/profiles/system-admin.profile.ts +++ b/src/profiles/system-admin.profile.ts @@ -28,6 +28,8 @@ export const SystemAdminProfile = { crm_campaign: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, crm_case: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true, allowExport: true }, crm_task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + crm_event: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + crm_event_attendee: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, // The objects below shipped with navigation, views, hooks and seed data // but no grant in ANY permission set (#488): "Knowledge" and "Forecasts" // were permission-denied nav items for every user, admins included. diff --git a/src/translations/en.ts b/src/translations/en.ts index a87d1c0c..20f2e8ee 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -8,6 +8,54 @@ import type { TranslationData } from '@objectstack/spec/system'; * Per-locale file: one file per language, following the `per_locale` convention. * Each file exports a single `TranslationData` object for its locale. */ +/** + * The activity family (#592) — `log_call`, `log_meeting` and + * `schedule_meeting` are registered once PER OBJECT (lead, contact, account, + * opportunity, case), because a body action with no `objectName` lands under a + * dispatcher key nothing probes (#509). The labels are identical on every one + * of them, so they are declared once here and spread into each object's + * `_actions` — fifteen hand-copied blocks per locale is how a translation set + * drifts. + */ +const activityActions = { + log_call: { + label: 'Log a Call', + successMessage: 'Call logged successfully!', + params: { + subject: { label: 'Call Subject' }, + duration: { label: 'Duration (minutes)' }, + attendee_contacts: { label: 'Contact Attendees' }, + attendee_users: { label: 'Internal Attendees' }, + notes: { label: 'Call Notes' }, + }, + }, + log_meeting: { + label: 'Log a Meeting', + successMessage: 'Meeting logged successfully!', + params: { + subject: { label: 'Meeting Subject' }, + duration: { label: 'Duration (minutes)' }, + attendee_contacts: { label: 'Contact Attendees' }, + attendee_users: { label: 'Internal Attendees' }, + notes: { label: 'Meeting Notes' }, + }, + }, + schedule_meeting: { + label: 'Schedule a Meeting', + successMessage: 'Meeting scheduled!', + params: { + subject: { label: 'Meeting Subject' }, + start_date: { label: 'Start Date (UTC)' }, + start_time: { label: 'Start Time (UTC)' }, + location: { label: 'Location' }, + duration: { label: 'Duration (minutes)' }, + attendee_contacts: { label: 'Contact Attendees' }, + attendee_users: { label: 'Internal Attendees' }, + notes: { label: 'Agenda' }, + }, + }, +}; + export const en: TranslationData = { objects: { crm_account: { @@ -70,6 +118,7 @@ export const en: TranslationData = { enterprise_accounts: { label: 'Enterprise Accounts', description: 'Accounts with the highest annual revenue' }, my_accounts: { label: 'My Accounts', description: 'Accounts owned by the current user' }, }, + _actions: { ...activityActions }, }, crm_contact: { @@ -107,6 +156,7 @@ export const en: TranslationData = { lead_source: { label: 'Lead Source' }, do_not_call: { label: 'Do Not Call' }, email_opt_out: { label: 'Email Opt Out' }, + last_contacted_date: { label: 'Last Contacted' }, }, _views: { all_contacts: { label: 'All Contacts' }, @@ -114,6 +164,7 @@ export const en: TranslationData = { primary_contacts: { label: 'Primary Contacts' }, }, _actions: { + ...activityActions, mark_primary: { label: 'Mark as Primary Contact', confirmText: 'Mark this contact as the primary contact for the account?', @@ -280,6 +331,7 @@ export const en: TranslationData = { suspected_duplicates: { label: 'Suspected Duplicates' }, }, _actions: { + ...activityActions, convert_lead: { label: 'Convert Lead', confirmText: 'Are you sure you want to convert this lead?', @@ -380,6 +432,7 @@ export const en: TranslationData = { my_open_deals: { label: 'My Open Deals' }, }, _actions: { + ...activityActions, clone_opportunity: { label: 'Clone Opportunity', successMessage: 'Opportunity cloned successfully!', @@ -436,20 +489,7 @@ export const en: TranslationData = { escalated_cases: { label: 'Escalated Cases' }, }, _actions: { - log_call: { - label: 'Log a Call', - successMessage: 'Call logged successfully!', - }, - log_meeting: { - label: 'Log a Meeting', - successMessage: 'Meeting logged successfully!', - params: { - subject: { label: 'Meeting Subject' }, - duration: { label: 'Duration (minutes)' }, - attendees: { label: 'Attendees' }, - notes: { label: 'Meeting Notes' }, - }, - }, + ...activityActions, escalate_case: { label: 'Escalate Case', confirmText: 'This will escalate the case to the escalation team. Continue?', @@ -640,6 +680,86 @@ export const en: TranslationData = { }, }, + crm_event: { + label: 'Event', + pluralLabel: 'Events', + description: 'Meetings, calls and other scheduled interactions with customers', + fields: { + subject: { label: 'Subject' }, + description: { label: 'Description' }, + type: { + label: 'Event Type', + options: { + meeting: 'Meeting', call: 'Call', demo: 'Demo', + webinar: 'Webinar', onsite_visit: 'Onsite Visit', other: 'Other', + }, + }, + status: { + label: 'Status', + options: { + planned: 'Planned', held: 'Held', cancelled: 'Cancelled', no_show: 'No Show', + }, + }, + owner: { label: 'Assigned To' }, + start_datetime: { label: 'Start' }, + end_datetime: { label: 'End' }, + all_day: { label: 'All Day Event' }, + duration_minutes: { label: 'Duration (minutes)' }, + location: { label: 'Location', help: 'Room, address, or meeting link' }, + related_to_type: { + label: 'Related To Type', + options: { + crm_account: 'Account', crm_contact: 'Contact', crm_opportunity: 'Opportunity', + crm_lead: 'Lead', crm_case: 'Case', + }, + }, + related_to_account: { label: 'Related Account' }, + related_to_contact: { label: 'Related Contact' }, + related_to_opportunity: { label: 'Related Opportunity' }, + related_to_lead: { label: 'Related Lead' }, + related_to_case: { label: 'Related Case' }, + outcome_notes: { label: 'Outcome Notes', help: 'What was agreed, and what happens next' }, + }, + _views: { + all_events: { label: 'All Events' }, + event_calendar: { label: 'Event Calendar' }, + event_timeline: { label: 'Team Schedule' }, + my_events: { label: 'My Calendar' }, + upcoming_events: { label: '📅 Upcoming · Soonest First' }, + held_events: { label: '✅ Interaction History' }, + }, + }, + + crm_event_attendee: { + label: 'Event Attendee', + pluralLabel: 'Event Attendees', + description: 'A person invited to or present at an event', + fields: { + attendee_number: { label: 'Attendee Number' }, + crm_event: { label: 'Event' }, + attendee_type: { + label: 'Attendee Type', + options: { contact: 'Contact', lead: 'Lead', user: 'User' }, + }, + crm_contact: { label: 'Contact' }, + crm_lead: { label: 'Lead' }, + sys_user: { label: 'User' }, + external_name: { label: 'External Attendee' }, + response: { + label: 'Response', + options: { + no_response: 'No Response', accepted: 'Accepted', + declined: 'Declined', tentative: 'Tentative', + }, + }, + is_organizer: { label: 'Organizer' }, + invited_date: { label: 'Invited' }, + }, + _views: { + all_event_attendees: { label: 'Event Attendees' }, + }, + }, + crm_campaign_member: { label: 'Campaign Member', pluralLabel: 'Campaign Members', @@ -711,6 +831,7 @@ export const en: TranslationData = { label: 'HotCRM', description: 'Customer relationship management for sales, service, and marketing', navigation: { + group_activity: { label: 'Activity' }, group_sales: { label: 'Sales' }, group_service: { label: 'Service' }, group_marketing: { label: 'Marketing' }, @@ -744,6 +865,25 @@ export const en: TranslationData = { dashboards: { + sales_activity_dashboard: { + label: 'Sales Activity', + description: 'Who is talking to customers, how often, and which accounts have gone quiet', + widgets: { + interactions_held: { title: 'Interactions Logged', description: 'Calls and meetings that actually happened' }, + meetings_booked: { title: 'Meetings Booked', description: 'Meetings on the calendar that have not happened yet' }, + customer_minutes: { title: 'Customer Minutes', description: 'Total time spent in front of customers' }, + tasks_completed: { title: 'Tasks Completed', description: 'Follow-ups closed out — the other half of activity' }, + activity_by_rep: { title: 'Activity by Rep', description: 'Logged interactions per owner' }, + activity_by_week: { title: 'Activity Volume by Week', description: 'Interactions per week' }, + activity_mix: { title: 'Activity Mix', description: 'Calls vs meetings vs demos' }, + activity_by_record_type: { title: 'Where the Activity Lands', description: 'Which part of the funnel is getting attention' }, + deal_activity: { title: 'Interactions on Deals', description: 'Logged interactions linked to an opportunity' }, + open_deals_for_activity: { title: 'Open Deals', description: 'Opportunities still in play' }, + quiet_accounts_30: { title: 'Quiet 30+ Days', description: 'Active accounts with no logged interaction in a month' }, + quiet_accounts_60: { title: 'Quiet 60+ Days', description: 'Two months of silence — the at-risk threshold' }, + quiet_accounts_90: { title: 'Quiet 90+ Days', description: 'A quarter with no contact' }, + }, + }, crm_overview_dashboard: { label: 'CRM Overview', description: 'Revenue metrics, pipeline analytics, and deal insights', diff --git a/src/translations/es-ES.ts b/src/translations/es-ES.ts index 72776397..dbc6dcd3 100644 --- a/src/translations/es-ES.ts +++ b/src/translations/es-ES.ts @@ -7,6 +7,52 @@ import type { TranslationData } from '@objectstack/spec/system'; * * Per-locale file: one file per language, following the `per_locale` convention. */ +/** + * Familia de acciones de actividad (#592): `log_call`, `log_meeting` y + * `schedule_meeting` se registran una vez POR OBJETO (prospecto, contacto, + * cuenta, oportunidad, caso), porque una acción de script sin `objectName` + * queda bajo una clave que el despachador nunca consulta (#509). Los textos son + * idénticos en los cinco, así que se declaran una vez aquí. + */ +const activityActions = { + log_call: { + label: 'Registrar llamada', + successMessage: '¡Llamada registrada exitosamente!', + params: { + subject: { label: 'Asunto de la llamada' }, + duration: { label: 'Duración (minutos)' }, + attendee_contacts: { label: 'Contactos participantes' }, + attendee_users: { label: 'Participantes internos' }, + notes: { label: 'Notas de la llamada' }, + }, + }, + log_meeting: { + label: 'Registrar reunión', + successMessage: '¡Reunión registrada exitosamente!', + params: { + subject: { label: 'Asunto de la reunión' }, + duration: { label: 'Duración (minutos)' }, + attendee_contacts: { label: 'Contactos participantes' }, + attendee_users: { label: 'Participantes internos' }, + notes: { label: 'Notas de la reunión' }, + }, + }, + schedule_meeting: { + label: 'Programar reunión', + successMessage: '¡Reunión programada!', + params: { + subject: { label: 'Asunto de la reunión' }, + start_date: { label: 'Fecha de inicio (UTC)' }, + start_time: { label: 'Hora de inicio (UTC)' }, + location: { label: 'Ubicación' }, + duration: { label: 'Duración (minutos)' }, + attendee_contacts: { label: 'Contactos participantes' }, + attendee_users: { label: 'Participantes internos' }, + notes: { label: 'Agenda' }, + }, + }, +}; + export const esES: TranslationData = { objects: { crm_account: { @@ -54,6 +100,7 @@ export const esES: TranslationData = { enterprise_accounts: { label: 'Cuentas Empresariales', description: 'Cuentas con mayores ingresos anuales' }, my_accounts: { label: 'Mis Cuentas', description: 'Cuentas asignadas al usuario actual' }, }, + _actions: { ...activityActions }, }, crm_contact: { @@ -91,6 +138,7 @@ export const esES: TranslationData = { lead_source: { label: 'Origen del Prospecto' }, do_not_call: { label: 'No Llamar' }, email_opt_out: { label: 'Excluir de Correos' }, + last_contacted_date: { label: 'Último contacto' }, }, _views: { all_contacts: { label: 'Todos los Contactos' }, @@ -98,6 +146,7 @@ export const esES: TranslationData = { primary_contacts: { label: 'Contactos Principales' }, }, _actions: { + ...activityActions, mark_primary: { label: 'Marcar como Principal', confirmText: '¿Establecer este contacto como contacto principal de la cuenta?', @@ -258,6 +307,7 @@ export const esES: TranslationData = { suspected_duplicates: { label: 'Duplicados Sospechosos' }, }, _actions: { + ...activityActions, convert_lead: { label: 'Convertir Prospecto', confirmText: '¿Está seguro de querer convertir este prospecto?', @@ -358,6 +408,7 @@ export const esES: TranslationData = { my_open_deals: { label: 'Mis Negocios Abiertos' }, }, _actions: { + ...activityActions, clone_opportunity: { label: 'Clonar Oportunidad', successMessage: '¡Oportunidad clonada con éxito!', @@ -414,20 +465,7 @@ export const esES: TranslationData = { escalated_cases: { label: 'Casos Escalados' }, }, _actions: { - log_call: { - label: 'Registrar Llamada', - successMessage: '¡Llamada registrada con éxito!', - }, - log_meeting: { - label: 'Registrar Reunión', - successMessage: '¡Reunión registrada con éxito!', - params: { - subject: { label: 'Asunto de la Reunión' }, - duration: { label: 'Duración (minutos)' }, - attendees: { label: 'Asistentes' }, - notes: { label: 'Notas de la Reunión' }, - }, - }, + ...activityActions, escalate_case: { label: 'Escalar Caso', confirmText: 'Esto enviará el caso al equipo de escalación. ¿Continuar?', @@ -618,6 +656,86 @@ export const esES: TranslationData = { }, }, + crm_event: { + label: 'Evento', + pluralLabel: 'Eventos', + description: 'Reuniones, llamadas y otras interacciones programadas con clientes', + fields: { + subject: { label: 'Asunto' }, + description: { label: 'Descripción' }, + type: { + label: 'Tipo de evento', + options: { + meeting: 'Reunión', call: 'Llamada', demo: 'Demostración', + webinar: 'Seminario web', onsite_visit: 'Visita presencial', other: 'Otro', + }, + }, + status: { + label: 'Estado', + options: { + planned: 'Planificado', held: 'Realizado', cancelled: 'Cancelado', no_show: 'No asistió', + }, + }, + owner: { label: 'Asignado a' }, + start_datetime: { label: 'Inicio' }, + end_datetime: { label: 'Fin' }, + all_day: { label: 'Evento de todo el día' }, + duration_minutes: { label: 'Duración (minutos)' }, + location: { label: 'Ubicación', help: 'Sala, dirección o enlace de la reunión' }, + related_to_type: { + label: 'Tipo de registro relacionado', + options: { + crm_account: 'Cuenta', crm_contact: 'Contacto', crm_opportunity: 'Oportunidad', + crm_lead: 'Prospecto', crm_case: 'Caso', + }, + }, + related_to_account: { label: 'Cuenta relacionada' }, + related_to_contact: { label: 'Contacto relacionado' }, + related_to_opportunity: { label: 'Oportunidad relacionada' }, + related_to_lead: { label: 'Prospecto relacionado' }, + related_to_case: { label: 'Caso relacionado' }, + outcome_notes: { label: 'Notas de resultado', help: 'Qué se acordó y qué sigue' }, + }, + _views: { + all_events: { label: 'Todos los eventos' }, + event_calendar: { label: 'Calendario de eventos' }, + event_timeline: { label: 'Agenda del equipo' }, + my_events: { label: 'Mi calendario' }, + upcoming_events: { label: '📅 Próximos · Más cercanos primero' }, + held_events: { label: '✅ Historial de interacciones' }, + }, + }, + + crm_event_attendee: { + label: 'Asistente al evento', + pluralLabel: 'Asistentes al evento', + description: 'Persona invitada o presente en un evento', + fields: { + attendee_number: { label: 'Número de asistente' }, + crm_event: { label: 'Evento' }, + attendee_type: { + label: 'Tipo de asistente', + options: { contact: 'Contacto', lead: 'Prospecto', user: 'Usuario' }, + }, + crm_contact: { label: 'Contacto' }, + crm_lead: { label: 'Prospecto' }, + sys_user: { label: 'Usuario' }, + external_name: { label: 'Asistente externo' }, + response: { + label: 'Respuesta', + options: { + no_response: 'Sin respuesta', accepted: 'Aceptado', + declined: 'Rechazado', tentative: 'Tentativo', + }, + }, + is_organizer: { label: 'Organizador' }, + invited_date: { label: 'Invitado el' }, + }, + _views: { + all_event_attendees: { label: 'Asistentes al evento' }, + }, + }, + crm_campaign_member: { label: 'Miembro de Campaña', pluralLabel: 'Miembros de Campaña', @@ -689,6 +807,7 @@ export const esES: TranslationData = { label: 'HotCRM', description: 'Gestión de relaciones con clientes para ventas, servicio y marketing', navigation: { + group_activity: { label: 'Actividad' }, group_sales: { label: 'Ventas' }, group_service: { label: 'Servicio' }, group_marketing: { label: 'Marketing' }, @@ -722,6 +841,25 @@ export const esES: TranslationData = { dashboards: { + sales_activity_dashboard: { + label: 'Actividad de ventas', + description: 'Quién habla con los clientes, con qué frecuencia y qué cuentas se han quedado en silencio', + widgets: { + interactions_held: { title: 'Interacciones registradas', description: 'Llamadas y reuniones que realmente ocurrieron' }, + meetings_booked: { title: 'Reuniones agendadas', description: 'Reuniones en el calendario que aún no ocurren' }, + customer_minutes: { title: 'Minutos con clientes', description: 'Tiempo total frente a clientes' }, + tasks_completed: { title: 'Tareas completadas', description: 'Seguimientos cerrados — la otra mitad de la actividad' }, + activity_by_rep: { title: 'Actividad por representante', description: 'Interacciones registradas por responsable' }, + activity_by_week: { title: 'Volumen de actividad por semana', description: 'Interacciones por semana' }, + activity_mix: { title: 'Composición de la actividad', description: 'Llamadas vs reuniones vs demostraciones' }, + activity_by_record_type: { title: 'Dónde cae la actividad', description: 'Qué parte del embudo recibe atención' }, + deal_activity: { title: 'Interacciones en oportunidades', description: 'Interacciones vinculadas a una oportunidad' }, + open_deals_for_activity: { title: 'Oportunidades abiertas', description: 'Oportunidades aún en juego' }, + quiet_accounts_30: { title: 'Silencio 30+ días', description: 'Cuentas activas sin interacción en un mes' }, + quiet_accounts_60: { title: 'Silencio 60+ días', description: 'Dos meses de silencio — el umbral de riesgo' }, + quiet_accounts_90: { title: 'Silencio 90+ días', description: 'Un trimestre sin contacto' }, + }, + }, crm_overview_dashboard: { label: 'Resumen CRM', description: 'Métricas de ingresos, analítica de pipeline e información de oportunidades', diff --git a/src/translations/ja-JP.ts b/src/translations/ja-JP.ts index d948dcaf..c17f4b51 100644 --- a/src/translations/ja-JP.ts +++ b/src/translations/ja-JP.ts @@ -7,6 +7,52 @@ import type { TranslationData } from '@objectstack/spec/system'; * * Per-locale file: one file per language, following the `per_locale` convention. */ +/** + * 活動アクション群(#592): `log_call` / `log_meeting` / `schedule_meeting` は + * リード・取引先責任者・取引先・商談・ケースの各オブジェクトごとに登録される + * (`objectName` を持たないスクリプトアクションはディスパッチャが参照しない + * キーに登録されるため — #509)。文言は 5 オブジェクトで同一なので、ここで + * 一度だけ定義して各 `_actions` に展開する。 + */ +const activityActions = { + log_call: { + label: '電話を記録', + successMessage: '電話を記録しました!', + params: { + subject: { label: '電話の件名' }, + duration: { label: '所要時間(分)' }, + attendee_contacts: { label: '取引先責任者の参加者' }, + attendee_users: { label: '社内参加者' }, + notes: { label: '通話メモ' }, + }, + }, + log_meeting: { + label: '会議を記録', + successMessage: '会議を記録しました!', + params: { + subject: { label: '会議の件名' }, + duration: { label: '所要時間(分)' }, + attendee_contacts: { label: '取引先責任者の参加者' }, + attendee_users: { label: '社内参加者' }, + notes: { label: '議事メモ' }, + }, + }, + schedule_meeting: { + label: '会議を設定', + successMessage: '会議を設定しました!', + params: { + subject: { label: '会議の件名' }, + start_date: { label: '開始日 (UTC)' }, + start_time: { label: '開始時刻 (UTC)' }, + location: { label: '場所' }, + duration: { label: '所要時間(分)' }, + attendee_contacts: { label: '取引先責任者の参加者' }, + attendee_users: { label: '社内参加者' }, + notes: { label: 'アジェンダ' }, + }, + }, +}; + export const jaJP: TranslationData = { objects: { crm_account: { @@ -54,6 +100,7 @@ export const jaJP: TranslationData = { enterprise_accounts: { label: 'エンタープライズ取引先', description: '年商最上位の主要顧客' }, my_accounts: { label: '私の取引先', description: '自分が所有する取引先' }, }, + _actions: { ...activityActions }, }, crm_contact: { @@ -91,6 +138,7 @@ export const jaJP: TranslationData = { lead_source: { label: 'リードソース' }, do_not_call: { label: '電話拒否' }, email_opt_out: { label: 'メール配信停止' }, + last_contacted_date: { label: '最終接触日時' }, }, _views: { all_contacts: { label: '全取引先責任者' }, @@ -98,6 +146,7 @@ export const jaJP: TranslationData = { primary_contacts: { label: '主担当者' }, }, _actions: { + ...activityActions, mark_primary: { label: '主担当者に設定', confirmText: 'この責任者を取引先の主担当者に設定しますか?', @@ -258,6 +307,7 @@ export const jaJP: TranslationData = { suspected_duplicates: { label: '重複の疑いがあるリード' }, }, _actions: { + ...activityActions, convert_lead: { label: 'リード変換', confirmText: 'このリードを変換してもよろしいですか?', @@ -357,6 +407,7 @@ export const jaJP: TranslationData = { my_open_deals: { label: '私のオープン商談' }, }, _actions: { + ...activityActions, clone_opportunity: { label: '商談を複製', successMessage: '商談を複製しました!', @@ -413,20 +464,7 @@ export const jaJP: TranslationData = { escalated_cases: { label: 'エスカレートしたケース' }, }, _actions: { - log_call: { - label: '通話を記録', - successMessage: '通話を記録しました!', - }, - log_meeting: { - label: '会議を記録', - successMessage: '会議を記録しました!', - params: { - subject: { label: '会議の件名' }, - duration: { label: '所要時間(分)' }, - attendees: { label: '参加者' }, - notes: { label: '議事メモ' }, - }, - }, + ...activityActions, escalate_case: { label: 'ケースをエスカレート', confirmText: 'このケースをエスカレーションチームへ引き継ぎます。続行しますか?', @@ -617,6 +655,86 @@ export const jaJP: TranslationData = { }, }, + crm_event: { + label: 'イベント', + pluralLabel: 'イベント', + description: '顧客との会議・電話などの予定された対話', + fields: { + subject: { label: '件名' }, + description: { label: '説明' }, + type: { + label: 'イベント種別', + options: { + meeting: '会議', call: '電話', demo: 'デモ', + webinar: 'ウェビナー', onsite_visit: '訪問', other: 'その他', + }, + }, + status: { + label: 'ステータス', + options: { + planned: '予定', held: '実施済み', cancelled: 'キャンセル', no_show: '無断欠席', + }, + }, + owner: { label: '担当者' }, + start_datetime: { label: '開始' }, + end_datetime: { label: '終了' }, + all_day: { label: '終日イベント' }, + duration_minutes: { label: '所要時間(分)' }, + location: { label: '場所', help: '会議室・住所・会議リンク' }, + related_to_type: { + label: '関連レコード種別', + options: { + crm_account: '取引先', crm_contact: '取引先責任者', crm_opportunity: '商談', + crm_lead: 'リード', crm_case: 'ケース', + }, + }, + related_to_account: { label: '関連取引先' }, + related_to_contact: { label: '関連取引先責任者' }, + related_to_opportunity: { label: '関連商談' }, + related_to_lead: { label: '関連リード' }, + related_to_case: { label: '関連ケース' }, + outcome_notes: { label: '結果メモ', help: '合意事項と次のアクション' }, + }, + _views: { + all_events: { label: 'すべてのイベント' }, + event_calendar: { label: 'イベントカレンダー' }, + event_timeline: { label: 'チームスケジュール' }, + my_events: { label: 'マイカレンダー' }, + upcoming_events: { label: '📅 開催予定 · 直近順' }, + held_events: { label: '✅ 対話履歴' }, + }, + }, + + crm_event_attendee: { + label: 'イベント参加者', + pluralLabel: 'イベント参加者', + description: 'イベントに招待された、または出席した人', + fields: { + attendee_number: { label: '参加者番号' }, + crm_event: { label: 'イベント' }, + attendee_type: { + label: '参加者種別', + options: { contact: '取引先責任者', lead: 'リード', user: '社内ユーザー' }, + }, + crm_contact: { label: '取引先責任者' }, + crm_lead: { label: 'リード' }, + sys_user: { label: '社内ユーザー' }, + external_name: { label: '社外参加者' }, + response: { + label: '回答', + options: { + no_response: '未回答', accepted: '承諾', + declined: '辞退', tentative: '仮承諾', + }, + }, + is_organizer: { label: '主催者' }, + invited_date: { label: '招待日時' }, + }, + _views: { + all_event_attendees: { label: 'イベント参加者' }, + }, + }, + crm_campaign_member: { label: 'キャンペーンメンバー', pluralLabel: 'キャンペーンメンバー', @@ -688,6 +806,7 @@ export const jaJP: TranslationData = { label: 'HotCRM', description: '営業・サービス・マーケティング向け顧客関係管理システム', navigation: { + group_activity: { label: '活動' }, group_sales: { label: '営業' }, group_service: { label: 'サービス' }, group_marketing: { label: 'マーケティング' }, @@ -721,6 +840,25 @@ export const jaJP: TranslationData = { dashboards: { + sales_activity_dashboard: { + label: '営業活動', + description: '誰がどれだけ顧客と話しているか、どの取引先が沈黙しているか', + widgets: { + interactions_held: { title: '記録済みの対話', description: '実際に行われた電話と会議' }, + meetings_booked: { title: '設定済みの会議', description: 'カレンダー上にあり未実施の会議' }, + customer_minutes: { title: '顧客接触時間(分)', description: '顧客と向き合った総時間' }, + tasks_completed: { title: '完了タスク', description: 'クローズしたフォローアップ' }, + activity_by_rep: { title: '担当者別の活動', description: '担当者ごとの記録済み対話数' }, + activity_by_week: { title: '週次の活動量', description: '週あたりの対話数' }, + activity_mix: { title: '活動の内訳', description: '電話・会議・デモの比率' }, + activity_by_record_type: { title: '活動の対象', description: 'ファネルのどこに注力しているか' }, + deal_activity: { title: '商談上の対話', description: '商談に紐づく記録済み対話' }, + open_deals_for_activity: { title: 'オープン商談', description: '進行中の商談数' }, + quiet_accounts_30: { title: '30 日以上沈黙', description: '1 か月間対話記録のないアクティブ取引先' }, + quiet_accounts_60: { title: '60 日以上沈黙', description: '2 か月の沈黙 — リスク閾値' }, + quiet_accounts_90: { title: '90 日以上沈黙', description: '四半期にわたり接触なし' }, + }, + }, crm_overview_dashboard: { label: 'CRM 概要', description: '売上指標、パイプライン分析、商談インサイト', diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index 2c713932..8c5ff9fa 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -7,6 +7,51 @@ import type { TranslationData } from '@objectstack/spec/system'; * * Per-locale file: one file per language, following the `per_locale` convention. */ +/** + * 活动动作族(#592):`log_call` / `log_meeting` / `schedule_meeting` 在 + * 线索、联系人、客户、商机、工单上各注册一次(无 `objectName` 的脚本动作会落到 + * 调度器从不探测的键上——见 #509)。五个对象的文案完全相同,因此在此声明一次并 + * 展开到各对象的 `_actions`:每个语言手抄十五份正是译文走样的开端。 + */ +const activityActions = { + log_call: { + label: '记录通话', + successMessage: '通话记录成功!', + params: { + subject: { label: '通话主题' }, + duration: { label: '时长(分钟)' }, + attendee_contacts: { label: '联系人参与者' }, + attendee_users: { label: '内部参与者' }, + notes: { label: '通话记录' }, + }, + }, + log_meeting: { + label: '记录会议', + successMessage: '会议记录成功!', + params: { + subject: { label: '会议主题' }, + duration: { label: '时长(分钟)' }, + attendee_contacts: { label: '联系人参会人' }, + attendee_users: { label: '内部参会人' }, + notes: { label: '会议纪要' }, + }, + }, + schedule_meeting: { + label: '安排会议', + successMessage: '会议已安排!', + params: { + subject: { label: '会议主题' }, + start_date: { label: '开始日期(UTC)' }, + start_time: { label: '开始时间(UTC)' }, + location: { label: '地点' }, + duration: { label: '时长(分钟)' }, + attendee_contacts: { label: '联系人参会人' }, + attendee_users: { label: '内部参会人' }, + notes: { label: '会议议程' }, + }, + }, +}; + export const zhCN: TranslationData = { objects: { crm_account: { @@ -82,6 +127,7 @@ export const zhCN: TranslationData = { branding: { label: '品牌' }, system: { label: '系统' }, }, + _actions: { ...activityActions }, }, crm_contact: { @@ -130,6 +176,7 @@ export const zhCN: TranslationData = { }, do_not_call: { label: '禁止致电' }, email_opt_out: { label: '拒绝邮件' }, + last_contacted_date: { label: '最近联系时间' }, avatar: { label: '头像' }, }, _views: { @@ -138,6 +185,7 @@ export const zhCN: TranslationData = { primary_contacts: { label: '主要联系人' }, }, _actions: { + ...activityActions, mark_primary: { label: '设为主要联系人', confirmText: '是否将此联系人设为该客户的主要联系人?', @@ -381,6 +429,7 @@ export const zhCN: TranslationData = { conversion: { label: '转化' }, }, _actions: { + ...activityActions, convert_lead: { label: '转化线索', confirmText: '确认要转化此线索吗?', @@ -580,25 +629,7 @@ export const zhCN: TranslationData = { sla_at_risk: { label: '⏰ SLA 风险预警' }, }, _actions: { - log_call: { - label: '记录通话', - successMessage: '通话记录成功!', - params: { - subject: { label: '通话主题' }, - duration: { label: '时长(分钟)' }, - notes: { label: '通话记录' }, - }, - }, - log_meeting: { - label: '记录会议', - successMessage: '会议记录成功!', - params: { - subject: { label: '会议主题' }, - duration: { label: '时长(分钟)' }, - attendees: { label: '参会人' }, - notes: { label: '会议纪要' }, - }, - }, + ...activityActions, escalate_case: { label: '升级工单', confirmText: '此操作会将工单升级到升级处理团队,是否继续?', @@ -921,6 +952,7 @@ export const zhCN: TranslationData = { notes: { label: '备注与下一步' }, }, _actions: { + ...activityActions, clone_opportunity: { label: '克隆商机', successMessage: '商机克隆成功!', @@ -946,6 +978,86 @@ export const zhCN: TranslationData = { }, }, + crm_event: { + label: '活动', + pluralLabel: '活动', + description: '与客户的会议、通话及其他已安排的互动', + fields: { + subject: { label: '主题' }, + description: { label: '描述' }, + type: { + label: '活动类型', + options: { + meeting: '会议', call: '电话', demo: '演示', + webinar: '线上研讨会', onsite_visit: '上门拜访', other: '其他', + }, + }, + status: { + label: '状态', + options: { + planned: '已计划', held: '已举行', cancelled: '已取消', no_show: '客户未到', + }, + }, + owner: { label: '负责人' }, + start_datetime: { label: '开始时间' }, + end_datetime: { label: '结束时间' }, + all_day: { label: '全天活动' }, + duration_minutes: { label: '时长(分钟)' }, + location: { label: '地点', help: '会议室、地址或会议链接' }, + related_to_type: { + label: '关联对象类型', + options: { + crm_account: '客户', crm_contact: '联系人', crm_opportunity: '商机', + crm_lead: '线索', crm_case: '工单', + }, + }, + related_to_account: { label: '关联客户' }, + related_to_contact: { label: '关联联系人' }, + related_to_opportunity: { label: '关联商机' }, + related_to_lead: { label: '关联线索' }, + related_to_case: { label: '关联工单' }, + outcome_notes: { label: '会后纪要', help: '达成了什么共识,下一步做什么' }, + }, + _views: { + all_events: { label: '全部活动' }, + event_calendar: { label: '活动日历' }, + event_timeline: { label: '团队日程' }, + my_events: { label: '我的日历' }, + upcoming_events: { label: '📅 即将开始 · 按时间升序' }, + held_events: { label: '✅ 互动历史' }, + }, + }, + + crm_event_attendee: { + label: '活动参与者', + pluralLabel: '活动参与者', + description: '受邀参加或实际出席活动的人员', + fields: { + attendee_number: { label: '参与者编号' }, + crm_event: { label: '活动' }, + attendee_type: { + label: '参与者类型', + options: { contact: '联系人', lead: '线索', user: '内部用户' }, + }, + crm_contact: { label: '联系人', help: '参与者是已有客户联系人时填写' }, + crm_lead: { label: '线索', help: '参与者仍是未转化线索时填写' }, + sys_user: { label: '内部用户', help: '参与者是同事时填写' }, + external_name: { label: '外部参与者', help: '不在 CRM 中的参与者姓名' }, + response: { + label: '回复', + options: { + no_response: '未回复', accepted: '已接受', + declined: '已拒绝', tentative: '待定', + }, + }, + is_organizer: { label: '组织者' }, + invited_date: { label: '邀请时间' }, + }, + _views: { + all_event_attendees: { label: '活动参与者' }, + }, + }, + crm_campaign_member: { label: '活动成员', pluralLabel: '活动成员', @@ -1018,6 +1130,12 @@ export const zhCN: TranslationData = { description: '涵盖销售、服务和市场营销的客户关系管理系统', // Keyed by navigation-node `id` (a flat keyspace regardless of depth). navigation: { + group_activity: { label: '活动' }, + nav_event: { label: '活动' }, + nav_event_calendar: { label: '日历' }, + nav_event_history: { label: '互动历史' }, + nav_activity_dashboard: { label: '销售活动' }, + nav_my_calendar: { label: '我的日历' }, nav_home: { label: '首页' }, group_sales: { label: '销售' }, @@ -1086,6 +1204,25 @@ export const zhCN: TranslationData = { dashboards: { + sales_activity_dashboard: { + label: '销售活动', + description: '谁在和客户沟通、频率如何,以及哪些客户已经沉默', + widgets: { + interactions_held: { title: '已记录互动', description: '真实发生过的通话与会议' }, + meetings_booked: { title: '已预约会议', description: '已排入日历但尚未举行的会议' }, + customer_minutes: { title: '客户接触分钟数', description: '面向客户的总时长' }, + tasks_completed: { title: '已完成任务', description: '已闭环的跟进事项——活动的另一半' }, + activity_by_rep: { title: '按销售代表统计的活动', description: '每位负责人记录的互动数' }, + activity_by_week: { title: '每周活动量', description: '每周互动数' }, + activity_mix: { title: '活动构成', description: '通话、会议与演示的占比' }, + activity_by_record_type: { title: '活动落在哪里', description: '漏斗的哪个环节获得了关注' }, + deal_activity: { title: '商机上的互动', description: '关联到商机的已记录互动' }, + open_deals_for_activity: { title: '进行中的商机', description: '仍在推进的商机数' }, + quiet_accounts_30: { title: '沉默 30 天以上', description: '一个月内没有任何互动记录的活跃客户' }, + quiet_accounts_60: { title: '沉默 60 天以上', description: '两个月无联系——风险阈值' }, + quiet_accounts_90: { title: '沉默 90 天以上', description: '整整一个季度没有联系' }, + }, + }, crm_overview_dashboard: { label: 'CRM 总览', description: '收入指标、管道分析与商机洞察', diff --git a/src/views/event.view.ts b/src/views/event.view.ts new file mode 100644 index 00000000..cdfa6fd1 --- /dev/null +++ b/src/views/event.view.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineView } from '@objectstack/spec/ui'; + +/** + * Event Views (#592) + * + * • grid — every interaction, most recent first + * • calendar — the schedule, start → end (the platform view type the issue asks for) + * • timeline — the same rows laid out per rep + * • mine — my calendar + * • upcoming — what is booked but has not happened yet + * • held — what actually happened, the interaction history + * + * # Why no "past events" filter on a date + * + * The list-view data path resolves ONLY the user tokens (`{current_user_id}`); + * date macros ship to the server as literal strings, and because + * `'2026-…' < '{…'` is lexicographically true, a `start_datetime < {today}` + * filter matches the FUTURE rows instead of the past ones — inverted, not + * merely empty (the same trap `overdue_tasks` documents). So the past/future + * split is expressed with `status`, which is authored and does not decay, and + * the ordering does the rest. + */ +export const EventViews = defineView({ + list: { + type: 'grid', + name: 'all_events', + label: 'All Events', + data: { provider: 'object', object: 'crm_event' }, + columns: [ + { field: 'subject', width: 280, sortable: true, link: true }, + { field: 'type', width: 120, sortable: true }, + { field: 'status', width: 120, sortable: true }, + { field: 'start_datetime', width: 180, sortable: true }, + { field: 'duration_minutes', width: 120, align: 'right' }, + { field: 'location', width: 180 }, + { field: 'owner', width: 150 }, + ], + sort: [{ field: 'start_datetime', order: 'desc' }], + rowColor: { + // Mirrors the option colors on crm_event.status. + field: 'status', + colors: { planned: '#4169E1', held: '#16a34a', cancelled: '#94a3b8', no_show: '#f97316' }, + }, + selection: { type: 'multiple' }, + pagination: { pageSize: 50 }, + appearance: { + showDescription: true, + allowedVisualizations: ['grid', 'calendar', 'timeline', 'kanban'], + }, + tabs: [ + { name: 'all', label: 'All', view: 'all_events', isDefault: true, pinned: true }, + { name: 'calendar', label: 'Calendar', icon: 'calendar', view: 'event_calendar' }, + { name: 'team', label: 'Team Schedule', icon: 'git-commit-horizontal', view: 'event_timeline' }, + { name: 'mine', label: 'My Calendar', icon: 'user', view: 'my_events' }, + { name: 'upcoming', label: 'Upcoming', icon: 'calendar-clock', view: 'upcoming_events' }, + { name: 'history', label: 'Interaction History', icon: 'history', view: 'held_events' }, + ], + }, + + listViews: { + /** The calendar the issue asks for — start → end, coloured by kind. */ + event_calendar: { + name: 'event_calendar', + type: 'calendar', + label: 'Event Calendar', + data: { provider: 'object', object: 'crm_event' }, + columns: ['subject', 'type', 'owner'], + calendar: { + startDateField: 'start_datetime', + endDateField: 'end_datetime', + titleField: 'subject', + colorField: 'type', + }, + }, + + /** Who is booked when — one lane per rep. */ + event_timeline: { + name: 'event_timeline', + type: 'timeline', + label: 'Team Schedule', + data: { provider: 'object', object: 'crm_event' }, + columns: ['subject', 'status'], + timeline: { + startDateField: 'start_datetime', + endDateField: 'end_datetime', + titleField: 'subject', + groupByField: 'owner', + colorField: 'type', + scale: 'day', + }, + }, + + my_events: { + name: 'my_events', + type: 'calendar', + label: 'My Calendar', + data: { provider: 'object', object: 'crm_event' }, + columns: ['subject', 'type', 'location'], + // `{current_user_id}` is the one token the list-view data path really + // interpolates (see crm.app.ts's "My Work" note). + filter: [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }], + calendar: { + startDateField: 'start_datetime', + endDateField: 'end_datetime', + titleField: 'subject', + colorField: 'type', + }, + }, + + /** Booked, not yet held — soonest first. */ + upcoming_events: { + name: 'upcoming_events', + type: 'grid', + label: '📅 Upcoming · Soonest First', + data: { provider: 'object', object: 'crm_event' }, + columns: ['subject', 'type', 'start_datetime', 'location', 'related_to_type', 'owner'], + filter: [{ field: 'status', operator: 'equals', value: 'planned' }], + sort: [{ field: 'start_datetime', order: 'asc' }], + }, + + /** What actually happened — the interaction history the CRM could not show. */ + held_events: { + name: 'held_events', + type: 'grid', + label: '✅ Interaction History', + data: { provider: 'object', object: 'crm_event' }, + columns: ['subject', 'type', 'start_datetime', 'duration_minutes', 'related_to_type', 'owner'], + filter: [{ field: 'status', operator: 'equals', value: 'held' }], + sort: [{ field: 'start_datetime', order: 'desc' }], + }, + }, + + form: { + type: 'simple', + sections: [ + { + label: 'Event', + columns: 2, + fields: [ + { field: 'subject', required: true, colSpan: 2 }, + { field: 'type', required: true }, + { field: 'status', required: true }, + 'owner', + 'location', + { field: 'description', colSpan: 2 }, + ], + }, + { + label: 'Schedule', + columns: 2, + fields: [ + { field: 'start_datetime', required: true }, + 'end_datetime', + 'all_day', + 'duration_minutes', + ], + }, + { + label: 'Related Records', + collapsible: true, + columns: 2, + fields: [ + 'related_to_account', + 'related_to_contact', + 'related_to_opportunity', + 'related_to_lead', + 'related_to_case', + ], + }, + { + label: 'Outcome', + collapsible: true, + collapsed: true, + columns: 1, + fields: ['outcome_notes'], + }, + ], + }, +}); diff --git a/src/views/event_attendee.view.ts b/src/views/event_attendee.view.ts new file mode 100644 index 00000000..93083aaf --- /dev/null +++ b/src/views/event_attendee.view.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineView } from '@objectstack/spec/ui'; + +/** + * Event Attendee Views (#592) + * + * A junction object is normally edited inside its parent's related list, and + * `crm_event_attendee` is no exception — but it still needs a grid and a form, + * for the same reason `crm_campaign_member` has them: the related list renders + * THIS view's columns, and the quick-create modal renders THIS form. Without + * them the panel falls back to every column in declaration order and the + * create modal offers the raw autonumber. + * + * It carries no `tabs` and no navigation entry: an attendee is never something + * you go looking for on its own, you reach it through the meeting. + */ +export const EventAttendeeViews = defineView({ + list: { + type: 'grid', + name: 'all_event_attendees', + label: 'Event Attendees', + data: { provider: 'object', object: 'crm_event_attendee' }, + columns: [ + { field: 'attendee_type', width: 120, sortable: true }, + { field: 'crm_contact', width: 200 }, + { field: 'crm_lead', width: 200 }, + { field: 'sys_user', width: 200 }, + { field: 'external_name', width: 200 }, + { field: 'response', width: 140, sortable: true }, + { field: 'is_organizer', width: 100, align: 'center' }, + ], + sort: [{ field: 'is_organizer', order: 'desc' }], + rowColor: { + // Mirrors the option colors on crm_event_attendee.response. + field: 'response', + colors: { accepted: '#16a34a', declined: '#dc2626', tentative: '#f97316', no_response: '#94a3b8' }, + }, + pagination: { pageSize: 25 }, + }, + + form: { + type: 'simple', + sections: [ + { + label: 'Attendee', + columns: 2, + fields: [ + { field: 'crm_event', required: true }, + { field: 'attendee_type', required: true }, + 'crm_contact', + 'crm_lead', + 'sys_user', + 'external_name', + ], + }, + { + label: 'Invitation', + columns: 2, + fields: ['response', 'is_organizer', 'invited_date'], + }, + ], + }, +}); diff --git a/src/views/index.ts b/src/views/index.ts index 3310b406..ee83fd09 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -8,6 +8,8 @@ export { TaskViews } from './task.view'; export { CaseViews } from './case.view'; export { CampaignViews } from './campaign.view'; export { ContractViews } from './contract.view'; +export { EventViews } from './event.view'; +export { EventAttendeeViews } from './event_attendee.view'; export { ForecastViews } from './forecast.view'; export { KnowledgeArticleViews } from './knowledge_article.view'; export { ProductViews } from './product.view'; diff --git a/test/action-sandbox.test.ts b/test/action-sandbox.test.ts index f14b542e..e838eb1a 100644 --- a/test/action-sandbox.test.ts +++ b/test/action-sandbox.test.ts @@ -40,9 +40,25 @@ import { type AnyRec = Record; const stackActions: AnyRec[] = (stack as any).actions ?? []; -const action = (name: string): AnyRec => { - const found = stackActions.find((a) => a.name === name); - if (!found) throw new Error(`no ${name} action registered`); + +/** + * An action's registry key — `:`, which is how the runtime + * itself keys `registerAction` and how the dispatcher resolves a call. + * + * A bare name stopped identifying an action in #592: `log_call`, `log_meeting` + * and `schedule_meeting` are registered once per sales object, so five distinct + * bodies answer to "log_call". Keying on the name alone silently exercised + * whichever one happened to be first in the array. + */ +const keyOf = (a: AnyRec): string => `${a.objectName ?? 'global'}:${a.name}`; + +const action = (key: string): AnyRec => { + const found = stackActions.find((a) => keyOf(a) === key) + // A bare name still resolves, for the actions that are unique by name. + ?? (stackActions.filter((a) => a.name === key).length === 1 + ? stackActions.find((a) => a.name === key) + : undefined); + if (!found) throw new Error(`no ${key} action registered (ambiguous or missing)`); return found; }; @@ -133,7 +149,10 @@ describe('the sandbox boundary is real', () => { // let it through with a shrug. This is the check that makes every // `capabilities: [...]` list in `src/actions/` load-bearing metadata // rather than documentation. - const stripped = { ...action('log_call'), body: { ...action('log_call').body, capabilities: [] } }; + // Addressed by registry key: since #592 five objects each register their + // own `log_call`, so a bare name no longer names one body. + const logCall = action('crm_case:log_call'); + const stripped = { ...logCall, body: { ...logCall.body, capabilities: [] } }; await expect( runActionBody(stripped, { objectName: 'crm_case', record: { id: 'case_1' }, input: { subject: 'x' } }), ).rejects.toThrow(/capability 'api\.write' not granted/); @@ -191,12 +210,6 @@ describe('every script action body executes under QuickJS', () => { create_campaign: { opts: { objectName: 'crm_lead', record: { id: 'lead_1' }, input: { crm_campaign: 'cmp_1' } }, }, - log_call: { - opts: { objectName: 'crm_case', record: { id: 'case_1', display_title: 'CASE-1' }, input: { subject: 'Intro call' } }, - }, - log_meeting: { - opts: { objectName: 'crm_case', record: { id: 'case_1', display_title: 'CASE-1' }, input: { subject: 'Kickoff' } }, - }, mark_primary: { opts: { objectName: 'crm_contact', record: { id: 'con_1' } }, seed: { crm_contact: [{ id: 'con_1', is_primary: false }] }, @@ -212,20 +225,54 @@ describe('every script action body executes under QuickJS', () => { opts: { objectName: 'crm_opportunity', record: { id: 'opp_1' }, input: { stage: 'negotiation' } }, seed: { crm_opportunity: [{ id: 'opp_1', stage: 'prospecting' }] }, }, + // The activity family (#592): the same three bodies, generated once per + // sales object. Each is invoked against ITS OWN object with that object's + // declared nameField in the record, because the body carries the resolved + // field name — a body dispatched against the wrong object would stamp a + // null label and nothing else would notice. + ...Object.fromEntries( + ([ + ['crm_lead', { id: 'lead_1', full_name: 'Ada Lovelace' }], + ['crm_contact', { id: 'con_1', full_name: 'Ada Lovelace' }], + ['crm_account', { id: 'acc_1', display_title: 'ACC-1 - Acme' }], + ['crm_opportunity', { id: 'opp_1', name: 'Acme Expansion' }], + ['crm_case', { id: 'case_1', display_title: 'CASE-1' }], + ] as Array<[string, Rec]>).flatMap(([objectName, record]) => [ + [`${objectName}:log_call`, { opts: { objectName, record, input: { subject: 'Intro call', duration: 15 } } }], + [`${objectName}:log_meeting`, { opts: { objectName, record, input: { subject: 'Kickoff' } } }], + [`${objectName}:schedule_meeting`, { + opts: { + objectName, + record, + input: { subject: 'Deep dive', start_date: '2026-09-01', start_time: '09:00', duration: 60, location: 'Zoom' }, + }, + }], + ]), + ), }; /** Asserted separately below, because it does not currently reach the engine. */ - const BROKEN = 'mass_update_stage'; + const BROKEN = 'crm_opportunity:mass_update_stage'; it('covers every action the runtime will sandbox', () => { - expect(Object.keys(INVOCATIONS).sort()).toEqual(SCRIPT_ACTIONS.map((a) => a.name).sort()); + // Keyed the way the runtime keys its own registry, so fifteen distinct + // activity bodies are fifteen distinct cases rather than three. + const covered = new Set(Object.keys(INVOCATIONS)); + const uncovered = SCRIPT_ACTIONS + .filter((a) => !covered.has(keyOf(a)) && !covered.has(a.name)) + .map(keyOf); + expect(uncovered, `script bodies nothing executes:\n ${uncovered.join('\n ')}`).toEqual([]); + const stale = [...covered].filter( + (k) => !SCRIPT_ACTIONS.some((a) => keyOf(a) === k || a.name === k), + ); + expect(stale, `INVOCATIONS names actions that no longer exist:\n ${stale.join('\n ')}`).toEqual([]); }); - it.each(SCRIPT_ACTIONS.map((a) => a.name).filter((n) => n !== BROKEN))('%s', async (name) => { - const { opts, seed } = INVOCATIONS[name]!; + it.each(SCRIPT_ACTIONS.map(keyOf).filter((k) => k !== BROKEN))('%s', async (key) => { + const { opts, seed } = (INVOCATIONS[key] ?? INVOCATIONS[key.split(':')[1]!])!; const engine = makeSandboxEngine(seed ?? {}); - const { result } = await runActionBody(action(name), { ...opts, engine }); - expect(result, `${name} returned nothing`).toBeTruthy(); + const { result } = await runActionBody(action(key), { ...opts, engine }); + expect(result, `${key} returned nothing`).toBeTruthy(); }); /** @@ -243,7 +290,7 @@ describe('every script action body executes under QuickJS', () => { * behaviour moved. */ it(`${BROKEN} is rejected by the engine — it passes an id where the facade wants a document`, async () => { - const { opts, seed } = INVOCATIONS[BROKEN]!; + const { opts, seed } = INVOCATIONS[BROKEN.split(':')[1]!]!; const engine = makeSandboxEngine(seed ?? {}); await expect(runActionBody(action(BROKEN), { ...opts, engine })).rejects.toThrow( /Update requires an ID or options\.multi=true/, diff --git a/test/activity-recency.test.ts b/test/activity-recency.test.ts new file mode 100644 index 00000000..1ba7b7f8 --- /dev/null +++ b/test/activity-recency.test.ts @@ -0,0 +1,377 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import stack from '../objectstack.config'; +import eventHooks from '../src/objects/event.hook'; +import taskHooks from '../src/objects/task.hook'; +import { makeHarness, makeDeniedApi, makeCtx, hookNamed, today, type Rec } from './helpers/hook-harness'; + +/** + * The activity model's recency contract (#592). + * + * `crm_account.last_activity_date` is the signal `at_risk_accounts` and + * `customer_churn_signals` are entirely built on, and until this issue **it was + * written by nothing at all**. Two independent defects stacked: + * + * 1. The only writer, `task_activity_bubble`, bubbled to the record the task + * NAMED. A rep names the opportunity or the contact, never the account, so + * the account's clock never moved. + * 2. Even when it did name the account, the write was silently discarded: the + * column was `readonly`, and `stripReadonlyFields` deletes a readonly key + * from any payload whose CALLER supplied it for every context that is not + * `isSystem` (#2948) — and a hook's `ctx.api` is a `ScopedContext` over the + * acting USER's execution context. + * + * Neither is visible to a metadata check, and neither is visible to a hook test + * that watches a stand-in's row change: the stand-in has no readonly semantics. + * So the second half of this file runs the write through a REAL ObjectQL over + * the REAL shipped object definitions, under a non-system context, which is the + * only place the strip actually happens. + */ + +type AnyRec = Record; + +const objects: AnyRec[] = (stack as any).objects ?? []; +const objectByName = new Map(objects.map((o) => [o.name as string, o])); + +const eventBubble = hookNamed(eventHooks, 'event_activity_bubble'); +const eventDerive = hookNamed(eventHooks, 'event_schedule_derive'); +const taskBubble = hookNamed(taskHooks, 'task_activity_bubble'); + +const USER = { id: 'user_1' }; + +// ─────────────────────────────────────────────── event_schedule_derive ── + +describe('event_schedule_derive keeps start / end / duration coherent', () => { + it('measures the duration when both ends are known', async () => { + const input: Rec = { + start_datetime: '2026-08-04T09:00:00.000Z', + end_datetime: '2026-08-04T09:45:00.000Z', + }; + await eventDerive.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.duration_minutes).toBe(45); + }); + + it('recomputes a duration that disagrees with its own timestamps', async () => { + // A stored duration a report averages must be a MEASUREMENT, not whatever + // the caller last typed. + const input: Rec = { + start_datetime: '2026-08-04T09:00:00.000Z', + end_datetime: '2026-08-04T10:00:00.000Z', + duration_minutes: 5, + }; + await eventDerive.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.duration_minutes).toBe(60); + }); + + it('materialises the end timestamp from a duration — the shape log_call submits', async () => { + const input: Rec = { start_datetime: '2026-08-04T09:00:00.000Z', duration_minutes: 20 }; + await eventDerive.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.end_datetime).toBe('2026-08-04T09:20:00.000Z'); + }); + + it('leaves an all-day event without a minute count', async () => { + const input: Rec = { + all_day: true, + start_datetime: '2026-08-04T00:00:00.000Z', + end_datetime: '2026-08-04T23:59:00.000Z', + }; + await eventDerive.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.duration_minutes).toBeUndefined(); + }); + + it('zeroes the duration of a cancelled or no-show meeting', async () => { + for (const status of ['cancelled', 'no_show']) { + const input: Rec = { + status, + start_datetime: '2026-08-04T09:00:00.000Z', + end_datetime: '2026-08-04T10:00:00.000Z', + }; + await eventDerive.handler(makeCtx({ event: 'beforeUpdate', input, user: USER })); + expect(input.duration_minutes, `${status} still books an hour`).toBe(0); + } + }); + + it('reads the effective value across input and previous on update', async () => { + const input: Rec = { end_datetime: '2026-08-04T09:30:00.000Z' }; + await eventDerive.handler(makeCtx({ + event: 'beforeUpdate', + input, + previous: { start_datetime: '2026-08-04T09:00:00.000Z' }, + user: USER, + })); + expect(input.duration_minutes).toBe(30); + }); +}); + +// ─────────────────────────────────────────────── event_activity_bubble ── + +describe('event_activity_bubble only fires for an interaction that happened', () => { + const held = (extra: Rec) => ({ + event: 'afterInsert' as const, + input: { id: 'evt_1', status: 'held', ...extra }, + user: USER, + }); + + it('a held event stamps the related account', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); + await eventBubble.handler(makeCtx({ ...held({ related_to_account: 'acc1' }), api: h.api })); + expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + }); + + it('a PLANNED meeting stamps nothing — a booking is not contact', async () => { + // This is the whole reason `status` exists on the object. Without the gate, + // dropping a placeholder on next quarter's calendar would make an account + // look freshly-touched today, and the churn report would go quiet again. + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); + await eventBubble.handler(makeCtx({ + event: 'afterInsert', + input: { id: 'evt_1', status: 'planned', related_to_account: 'acc1' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }); + + it('a cancelled meeting stamps nothing', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); + await eventBubble.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'evt_1', status: 'cancelled', related_to_account: 'acc1' }, + previous: { id: 'evt_1', status: 'planned' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }); + + it('fires once, on the transition into held', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); + await eventBubble.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 'evt_1', status: 'held', subject: 'renamed' }, + previous: { id: 'evt_1', status: 'held', related_to_account: 'acc1' }, + user: USER, + api: h.api, + })); + expect(h.calls).toHaveLength(0); + }); + + it('stamps the lead it was held with', async () => { + const h = makeHarness({ crm_lead: [{ id: 'l1' }] }); + await eventBubble.handler(makeCtx({ ...held({ related_to_lead: 'l1' }), api: h.api })); + expect(typeof h.rows('crm_lead')[0].last_contacted_date).toBe('string'); + }); + + it('walks up to the account from a contact, an opportunity and a case', async () => { + for (const [field, object] of [ + ['related_to_contact', 'crm_contact'], + ['related_to_opportunity', 'crm_opportunity'], + ['related_to_case', 'crm_case'], + ] as const) { + const h = makeHarness({ + crm_account: [{ id: 'acc1' }], + [object]: [{ id: 'x1', crm_account: 'acc1' }], + }); + await eventBubble.handler(makeCtx({ ...held({ [field]: 'x1' }), api: h.api })); + expect(h.rows('crm_account')[0].last_activity_date, `${object} did not reach its account`) + .toBe(today()); + } + }); + + it('writes the account only once when two links resolve to the same one', async () => { + const h = makeHarness({ + crm_account: [{ id: 'acc1' }], + crm_contact: [{ id: 'c1', crm_account: 'acc1' }], + crm_opportunity: [{ id: 'o1', crm_account: 'acc1' }], + }); + await eventBubble.handler(makeCtx({ + ...held({ related_to_account: 'acc1', related_to_contact: 'c1', related_to_opportunity: 'o1' }), + api: h.api, + })); + expect(h.callsFor('crm_account', 'update')).toHaveLength(1); + }); + + it('never propagates a write failure — the bubble is best-effort', async () => { + await expect( + eventBubble.handler(makeCtx({ + ...held({ related_to_account: 'acc1' }), + api: makeDeniedApi('denied'), + })), + ).resolves.toBeUndefined(); + }); + + it('stamps a DATE on the account and an INSTANT on the people', async () => { + // `crm_account.last_activity_date` is a `Field.date()` (TEXT YYYY-MM-DD, the + // shape every churn filter compares against); the two contact columns are + // datetimes. Passing an ISO instant into the date column is how a `< + // {60_days_ago}` filter starts comparing '2026-08-04T…' to '2026-06-05'. + const h = makeHarness({ + crm_account: [{ id: 'acc1' }], + crm_lead: [{ id: 'l1' }], + }); + await eventBubble.handler(makeCtx({ + ...held({ related_to_account: 'acc1', related_to_lead: 'l1' }), + api: h.api, + })); + expect(h.rows('crm_account')[0].last_activity_date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(h.rows('crm_lead')[0].last_contacted_date).toMatch(/T.*Z$/); + }); +}); + +// ───────────────────────────────────── the two bubble copies stay twins ── + +describe('the task and event bubbles are the same bubble', () => { + /** + * `event.hook.ts` carries the canonical copy and `task.hook.ts` a verbatim + * duplicate, because an L2 hook body ships body-only into QuickJS and a + * shared module helper arrives `undefined` at runtime. A duplicate that + * nothing compares is a duplicate that drifts, so both are driven through + * the same table here. + */ + const CASES: Array<{ label: string; links: Rec; seed: Rec; expect: (h: ReturnType) => void }> = [ + { + label: 'a directly-named account', + links: { related_to_account: 'acc1' }, + seed: { crm_account: [{ id: 'acc1' }] }, + expect: (h) => expect(h.rows('crm_account')[0].last_activity_date).toBe(today()), + }, + { + label: 'a contact and the account above it', + links: { related_to_contact: 'c1' }, + seed: { crm_account: [{ id: 'acc1' }], crm_contact: [{ id: 'c1', crm_account: 'acc1' }] }, + expect: (h) => { + expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + expect(typeof h.rows('crm_contact')[0].last_contacted_date).toBe('string'); + }, + }, + { + label: 'an opportunity, through to its account', + links: { related_to_opportunity: 'o1' }, + seed: { crm_account: [{ id: 'acc1' }], crm_opportunity: [{ id: 'o1', crm_account: 'acc1' }] }, + expect: (h) => expect(h.rows('crm_account')[0].last_activity_date).toBe(today()), + }, + { + label: 'a lead', + links: { related_to_lead: 'l1' }, + seed: { crm_lead: [{ id: 'l1' }] }, + expect: (h) => expect(typeof h.rows('crm_lead')[0].last_contacted_date).toBe('string'), + }, + ]; + + it.each(CASES.map((c) => c.label))('%s bubbles identically from a task and from an event', async (label) => { + const spec = CASES.find((c) => c.label === label)!; + + const viaEvent = makeHarness(JSON.parse(JSON.stringify(spec.seed))); + await eventBubble.handler(makeCtx({ + event: 'afterInsert', + input: { id: 'evt_1', status: 'held', ...spec.links }, + user: USER, + api: viaEvent.api, + })); + spec.expect(viaEvent); + + const viaTask = makeHarness(JSON.parse(JSON.stringify(spec.seed))); + await taskBubble.handler(makeCtx({ + event: 'afterUpdate', + input: { id: 't1', status: 'completed', ...spec.links }, + previous: { id: 't1', status: 'in_progress' }, + user: USER, + api: viaTask.api, + })); + spec.expect(viaTask); + + // Same objects touched, same fields written. + const shape = (h: ReturnType) => + h.calls + .filter((c) => c.op === 'update') + .map((c) => `${c.object}:${Object.keys(c.args[0] as Rec).sort().join(',')}`) + .sort(); + expect(shape(viaTask)).toEqual(shape(viaEvent)); + }); +}); + +// ───────────────────── the recency columns must actually accept the write ── + +describe('the recency columns are writable by a non-system caller (#2948)', () => { + /** + * The regression that made the whole churn story fiction. + * + * `stripReadonlyFields` runs `if (!opCtx.context?.isSystem)` and deletes every + * readonly key the caller supplied, logging `Field 'x' is read-only — ignoring + * incoming change (#2948)` and continuing. A hook's `ctx.api` is built by + * `buildHookApi(execCtx)` → `new ScopedContext(execCtx, this)` over the ACTING + * USER's context, so every bubble the app ever performed was thrown away here. + * + * This runs the real engine over the real shipped field definitions, under a + * plain user context, which is the only configuration where the strip fires. + * A `readonly: true` creeping back onto any of these three columns fails here. + */ + const RECENCY: Array<[string, string, string]> = [ + ['crm_account', 'last_activity_date', '2026-08-04'], + ['crm_lead', 'last_contacted_date', '2026-08-04T09:00:00.000Z'], + ['crm_contact', 'last_contacted_date', '2026-08-04T09:00:00.000Z'], + ]; + + it('none of the three is declared readonly', () => { + const bad = RECENCY + .filter(([object, field]) => objectByName.get(object)?.fields?.[field]?.readonly === true) + .map(([object, field]) => `${object}.${field}`); + expect( + bad, + 'a readonly recency column is a column the activity bubble cannot write — ' + + `the engine drops the key and logs a warning nobody reads (#2948):\n ${bad.join('\n ')}`, + ).toEqual([]); + }); + + let ql: Awaited>; + + beforeAll(async () => { + // The shipped field definitions, not a hand-written stand-in: whether the + // strip fires is decided by `field.readonly` on the REAL schema. + const shipped = Object.fromEntries( + RECENCY.map(([object]) => [ + object, + { name: object, fields: objectByName.get(object)?.fields ?? {} }, + ]), + ); + ql = await ObjectQL.create({ + datasources: { default: new InMemoryDriver({ persistence: false }) }, + objects: shipped as never, + }); + }); + + afterAll(async () => { + await ql?.close(); + }); + + it.each(RECENCY)('%s.%s survives a write from a plain user context', async (object, field, value) => { + // `isSystem` deliberately absent — this is the context a hook fired by a + // rep's own save actually runs under. + const api = ql.createContext({ userId: 'user_1' } as never); + // `crm_contact.crm_account` is a REQUIRED master-detail and the engine + // resolves references on insert, so the parent has to genuinely exist. + const parent = await api.object('crm_account').insert({ name: 'Acme Industrial' }); + const row = await api.object(object).insert(seedFor(object, parent.id as string)); + await api.object(object).update({ id: row.id, [field]: value }, { where: { id: row.id } }); + const after = await api.object(object).findOne({ where: { id: row.id } }); + expect( + (after as AnyRec)?.[field], + `${object}.${field} was discarded — the engine stripped it as readonly (#2948)`, + ).toBe(value); + }); + + /** The minimum a row of each object needs to satisfy its shipped required fields. */ + function seedFor(object: string, accountId: string): AnyRec { + if (object === 'crm_account') return { name: 'Acme Industrial' }; + if (object === 'crm_lead') { + return { first_name: 'Ada', last_name: 'Lovelace', company: 'Acme', email: 'ada@acme.test' }; + } + return { + first_name: 'Ada', last_name: 'Lovelace', + email: 'ada@acme.test', crm_account: accountId, + }; + } +}); diff --git a/test/case-first-response.test.ts b/test/case-first-response.test.ts index 70221909..c66687fa 100644 --- a/test/case-first-response.test.ts +++ b/test/case-first-response.test.ts @@ -31,16 +31,25 @@ import { makeSandboxEngine, runActionBody, type Rec } from './helpers/action-san type AnyRec = Record; const stackActions: AnyRec[] = (stack as any).actions ?? []; -const action = (name: string): AnyRec => { - const found = stackActions.find((a) => a.name === name); - if (!found) throw new Error(`no ${name} action registered`); + +/** + * An action addressed the way the runtime keys its registry — + * `:`. + * + * Since #592 five objects each register their own `log_call`, so a bare-name + * lookup returns whichever one the barrel happens to export first. That is not + * a detail to leave to export order in a file about which object gets stamped. + */ +const action = (objectName: string, name: string): AnyRec => { + const found = stackActions.find((a) => a.objectName === objectName && a.name === name); + if (!found) throw new Error(`no ${objectName}-scoped ${name} action registered`); return found; }; const objects: AnyRec[] = (stack as any).objects ?? []; const crmCase = objects.find((o) => o.name === 'crm_case') as AnyRec | undefined; -/** Both activity twins are built by the same constructor — both must stamp. */ +/** Both LOGGING twins are built by the same constructor — both must stamp. */ const TWINS = ['log_call', 'log_meeting'] as const; const seeded = (first_response_date: string | null = null): Record => ({ @@ -48,7 +57,7 @@ const seeded = (first_response_date: string | null = null): Record, record: Rec = { id: 'case_1' }) => - runActionBody(action(name), { + runActionBody(action('crm_case', name), { objectName: 'crm_case', record, input: { subject: 'Called the customer back', duration: 12 }, @@ -79,8 +88,8 @@ describe.each(TWINS)('%s stamps the first response', (name) => { it('declares the read capability the lookup needs', () => { // Without it the sandbox denies the `find` at call time and the whole // action fails — the capability list is load-bearing metadata, not prose. - expect(action(name).body?.capabilities).toContain('api.read'); - expect(action(name).body?.capabilities).toContain('api.write'); + expect(action('crm_case', name).body?.capabilities).toContain('api.read'); + expect(action('crm_case', name).body?.capabilities).toContain('api.write'); }); it('writes the current time onto a case that has none', async () => { @@ -111,11 +120,11 @@ describe.each(TWINS)('%s stamps the first response', (name) => { }); it('leaves other objects alone', async () => { - // The twins are scoped to crm_case today, but the body's own comment keeps - // the global design alive as a restoration path — the object check is what - // stops this stamp from following it onto objects with no such field. + // #592 put the same body on five objects. The lead-scoped twin must not + // reach for a case field that only exists on `crm_case` — the object check + // inside the body is what stops the stamp travelling with the family. const engine = makeSandboxEngine({ crm_lead: [{ id: 'lead_1' }] }); - await runActionBody(action(name), { + await runActionBody(action('crm_lead', name), { objectName: 'crm_lead', record: { id: 'lead_1' }, input: { subject: 'Intro call' }, @@ -123,6 +132,21 @@ describe.each(TWINS)('%s stamps the first response', (name) => { }); expect(engine.callsFor('crm_case')).toHaveLength(0); }); + + it('is not stamped by merely BOOKING a meeting', async () => { + // `schedule_meeting` writes a `planned` event. A meeting on next week's + // calendar is not a response the customer has received, so it must not + // start the SLA clock — the same `held` gate the recency bubble uses. + const engine = makeSandboxEngine(seeded()); + await runActionBody(action('crm_case', 'schedule_meeting'), { + objectName: 'crm_case', + record: { id: 'case_1' }, + input: { subject: 'Follow-up', start_date: '2026-09-01', start_time: '09:00' }, + engine, + }); + expect(engine.rows('crm_case')[0]!.first_response_date).toBeNull(); + expect(engine.callsFor('crm_case', 'update')).toHaveLength(0); + }); }); describe('the stamp survives the ways a case reaches the body', () => { diff --git a/test/dataset-granularity.test.ts b/test/dataset-granularity.test.ts index 70c5201c..aa28e220 100644 --- a/test/dataset-granularity.test.ts +++ b/test/dataset-granularity.test.ts @@ -57,6 +57,11 @@ const INTENDED_BUCKET: Record = { 'account_metrics.created_at': 'month', 'opportunity_metrics.close_date': 'month', 'opportunity_metrics.close_quarter': 'quarter', + // #592. Every widget over this dimension asks "how much activity per + // week", and the week bucket is what puts the trend on an AXIS instead of + // in a datetime filter bound — which is the thing the app cannot trust yet + // (see the Activity dashboard's header note on #3912/#3777). + 'event_metrics.start_datetime': 'week', }; /** Matrix report → the bucket its date axis must carry on @objectstack 17+. */ diff --git a/test/global-actions.test.ts b/test/global-actions.test.ts index af442ebb..a607373b 100644 --- a/test/global-actions.test.ts +++ b/test/global-actions.test.ts @@ -1,223 +1,558 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { validateActionParams } from '@objectstack/spec/ui'; import stack from '../objectstack.config'; -import { runActionBody, type ActionRunOpts } from './helpers/action-sandbox'; +import { ACTIVITY_TARGETS } from '../src/actions/global.actions'; +import eventHooks from '../src/objects/event.hook'; +import { runActionBody, makeSandboxEngine, type ActionRunOpts } from './helpers/action-sandbox'; +import { makeHarness, makeCtx, hookNamed } from './helpers/hook-harness'; /** - * Behavioral guards for the activity-logging actions in - * `src/actions/global.actions.ts` (issue #514, items 2 and 15). + * Behavioral guards for the activity actions in `src/actions/global.actions.ts`. * - * These EXECUTE the action bodies instead of regex-matching them. Both bugs - * pinned here are invisible to `os validate`, to `build`, and to a structural - * assertion over the compiled metadata: the first lived in a field read inside - * the sandboxed body (`ctx.record?.name` on objects that have no `name` - * column), the second in two param lists that had quietly stopped agreeing - * with each other. A source-text assertion would have pinned the fix's - * spelling; running the body pins its behavior. + * These EXECUTE the action bodies instead of regex-matching them, through the + * same `actionBodyRunnerFactory` + QuickJS the runtime binds at boot + * (`test/helpers/action-sandbox.ts`). Everything pinned here is invisible to + * `os validate`, to `build`, and to a structural assertion over the compiled + * metadata — it lives inside a sandboxed body. * - * The execution is the real one (#575 A1): `test/helpers/action-sandbox.ts` - * runs these bodies under QuickJS through the same body-runner factory the - * runtime binds at boot, so the ctx they see is the dispatcher's — notably - * WITHOUT `ctx.object`, which is why the label lookup below has to reach - * `input.objectName`. The previous `new Function` stand-in could not have - * shown that. `test/action-sandbox.test.ts` covers the sandbox's own - * constraints; this file stays about what these two actions compute. + * # What this file is about now (#592) + * + * It was about two defects in a pair of twins (#514 items 2 and 15). The pair + * is now a FAMILY — `log_call`, `log_meeting` and `schedule_meeting`, generated + * once per sales object — and what they write changed from "a `sys_activity` + * row with the interesting part in a JSON string" to "a real `crm_event` plus + * real `crm_event_attendee` rows, with the `sys_activity` row kept as the + * timeline pointer". So the guards are: + * + * 1. the family is complete and reachable on every object a rep sells to + * (the #509 workaround scoped everything to `crm_case`); + * 2. attendees are ROWS — the acceptance criterion of #592; + * 3. a booking does not masquerade as an interaction; + * 4. the `record_label` and twin-parity guarantees from #514 still hold. */ type AnyRec = Record; const stackActions: AnyRec[] = (stack as any).actions ?? []; const stackObjects: AnyRec[] = (stack as any).objects ?? []; +const objectByName = new Map(stackObjects.map((o) => [o.name as string, o])); -/** An action by name, or a thrown error naming the miss. */ -const action = (name: string): AnyRec => { - const found = stackActions.find((a) => a.name === name); - if (!found) throw new Error(`no ${name} action registered`); +/** An action addressed by the key the runtime registers it under. */ +const action = (objectName: string, name: string): AnyRec => { + const found = stackActions.find((a) => a.objectName === objectName && a.name === name); + if (!found) throw new Error(`no ${objectName}-scoped ${name} action registered`); return found; }; -/** The two twins this file is about, always exercised as a pair. */ -const TWINS = ['log_call', 'log_meeting'] as const; +const KINDS = ['log_call', 'log_meeting', 'schedule_meeting'] as const; +/** Kinds that record something that HAPPENED, as opposed to a booking. */ +const LOGGING_KINDS = ['log_call', 'log_meeting'] as const; +const TARGETS = Object.keys(ACTIVITY_TARGETS); + +/** A record of each object, carrying that object's declared nameField. */ +const recordFor = (objectName: string): AnyRec => { + const nameField = objectByName.get(objectName)?.nameField ?? 'name'; + return { id: `${objectName}_1`, [nameField]: `label of ${objectName}` }; +}; -/** The single `sys_activity` row an activity action is expected to write. */ -async function loggedActivity(actionName: string, opts: ActionRunOpts = {}): Promise { - const { engine } = await runActionBody(action(actionName), opts); - const activities = engine.inserted('sys_activity'); - expect(activities, `${actionName} wrote ${activities.length} sys_activity rows, expected 1`).toHaveLength(1); - return activities[0]!; +async function run(objectName: string, kind: string, opts: ActionRunOpts = {}) { + const engine = opts.engine ?? makeSandboxEngine(); + const { result } = await runActionBody(action(objectName, kind), { + objectName, + record: recordFor(objectName), + ...opts, + input: { subject: 'Quarterly sync', ...(opts.input ?? {}) }, + engine, + }); + return { engine, result }; } -/** Objects that declare a `nameField`, i.e. that have a resolvable display name. */ -const objectsWithNameField = stackObjects.filter((o) => typeof o.nameField === 'string'); +// ────────────────────────────────────── the family exists, per object ── -describe('activity actions stamp a real record_label (#514 item 2)', () => { - it('the codebase still makes this worth guarding — most nameFields are not `name`', () => { - // If this ever drops to zero the guards below become vacuous: reading - // `record.name` would be right everywhere and the bug could not recur. - const notName = objectsWithNameField.filter((o) => o.nameField !== 'name'); - expect(objectsWithNameField.length, 'no object declares a nameField').toBeGreaterThan(0); +describe('a rep can log an interaction on everything they sell to (#509 / #592)', () => { + it('registers every kind on every activity target', () => { + const missing: string[] = []; + for (const objectName of TARGETS) { + for (const kind of KINDS) { + if (!stackActions.some((a) => a.objectName === objectName && a.name === kind)) { + missing.push(`${objectName}:${kind}`); + } + } + } expect( - notName.map((o) => o.name), - 'expected most objects to declare a nameField other than `name`', - ).not.toEqual([]); - expect(notName.length).toBeGreaterThan(objectsWithNameField.length / 2); + missing, + 'a rep cannot log activity here — a body action reachable from no surface ' + + `is the #509 defect:\n ${missing.join('\n ')}`, + ).toEqual([]); + }); + + it('covers the sales objects the issue names, not just the case', () => { + // The regression this stops: collapsing back to one crm_case-scoped pair. + for (const objectName of ['crm_lead', 'crm_contact', 'crm_account', 'crm_opportunity']) { + expect(TARGETS, `${objectName} is not an activity target`).toContain(objectName); + } + }); + + it('no activity action is left unscoped', () => { + // A body action with no `objectName` registers under a 'global' key the + // dispatcher never probes, which is how these were unreachable before they + // were pinned to crm_case. + const unscoped = stackActions + .filter((a) => (KINDS as readonly string[]).includes(a.name)) + .filter((a) => typeof a.objectName !== 'string' || !a.objectName); + expect(unscoped.map((a) => a.name), 'unreachable global body actions').toEqual([]); }); - it.each(TWINS)('%s resolves the declared nameField on every object', async (name) => { + it('every target names a real related_to_* lookup on crm_event', () => { + // The map in `global.actions.ts` decides which `crm_event` column records + // the link. A stale entry writes an event linked to nothing at all. + const eventFields = objectByName.get('crm_event')?.fields ?? {}; + const typeOptions = new Set( + (eventFields.related_to_type?.options ?? []).map((o: AnyRec) => o.value), + ); const bad: string[] = []; - for (const object of objectsWithNameField) { - const nameField: string = object.nameField; - const expected = `label of ${object.name}`; - const doc = await loggedActivity(name, { - objectName: object.name, - // A record as the dispatcher loads it: stored columns plus the - // materialised formula fields, of which `nameField` may be one. - record: { id: 'rec_1', [nameField]: expected }, - input: { subject: 'Quarterly sync' }, - }); - if (doc.record_label !== expected) { - bad.push(`${object.name}: nameField=${nameField} → record_label=${JSON.stringify(doc.record_label)}`); + for (const [objectName, field] of Object.entries(ACTIVITY_TARGETS)) { + if (!eventFields[field]) bad.push(`crm_event has no field "${field}" (for ${objectName})`); + if (!typeOptions.has(objectName)) { + bad.push(`crm_event.related_to_type has no option "${objectName}"`); } } - expect(bad, `${name} failed to resolve the display name:\n ${bad.join('\n ')}`).toEqual([]); + expect(bad, `ACTIVITY_TARGETS has drifted from crm_event:\n ${bad.join('\n ')}`).toEqual([]); }); +}); - it.each(TWINS)('%s labels a crm_case by display_title, the field it is scoped to', async (name) => { - // Both actions declare objectName: 'crm_case', whose nameField is the - // `display_title` formula — there is no `name` column on the object at - // all, so the old hardcoded read produced null on every single dispatch. - const doc = await loggedActivity(name, { - objectName: 'crm_case', - record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline', subject: 'Printer offline' }, - input: { subject: 'Follow-up with customer' }, +// ─────────────────────────────── attendees are records, not JSON strings ── + +describe('attendees are queryable records (#592 acceptance)', () => { + it('no activity body stashes an attendee list in metadata', () => { + // The exact shape the issue rejects: `{"attendees":"Bob, Alice"}` inside + // `sys_activity.metadata` — unqueryable, unreportable. + const offenders: string[] = []; + for (const objectName of TARGETS) { + for (const kind of KINDS) { + const source: string = action(objectName, kind).body?.source ?? ''; + if (/attendees:\s*input\.attendees/.test(source)) offenders.push(`${objectName}:${kind}`); + } + } + expect(offenders, `attendee list smuggled into metadata:\n ${offenders.join('\n ')}`).toEqual([]); + }); + + it('writes one crm_event_attendee row per person, linked to the event', async () => { + const { engine, result } = await run('crm_opportunity', 'log_meeting', { + input: { + subject: 'Kickoff', + attendee_contacts: ['con_1', 'con_2'], + attendee_users: ['usr_7'], + }, + }); + const attendees = engine.inserted('crm_event_attendee'); + + // organiser + two contacts + one colleague + expect(attendees).toHaveLength(4); + expect(attendees.every((a) => a.crm_event === result.eventId)).toBe(true); + + const organiser = attendees.find((a) => a.is_organizer === true); + expect(organiser, 'the acting user is always the organiser').toMatchObject({ + attendee_type: 'user', sys_user: 'usr_1', response: 'accepted', + }); + expect(attendees.filter((a) => a.attendee_type === 'contact').map((a) => a.crm_contact).sort()) + .toEqual(['con_1', 'con_2']); + expect(attendees.find((a) => a.sys_user === 'usr_7')).toMatchObject({ attendee_type: 'user' }); + // Every row is stamped, so "who was invited when" is answerable. + expect(attendees.every((a) => typeof a.invited_date === 'string')).toBe(true); + }); + + it('accepts a single value where the console renders the lookup unmultiplied', async () => { + const { engine } = await run('crm_account', 'log_meeting', { + input: { subject: 'Review', attendee_contacts: 'con_9' }, + }); + expect(engine.inserted('crm_event_attendee').some((a) => a.crm_contact === 'con_9')).toBe(true); + }); + + it('adds the record itself when it IS a person', async () => { + for (const [objectName, type, field] of [ + ['crm_contact', 'contact', 'crm_contact'], + ['crm_lead', 'lead', 'crm_lead'], + ] as const) { + const { engine } = await run(objectName, 'log_call'); + const self = engine.inserted('crm_event_attendee').find((a) => a.attendee_type === type); + expect(self, `${objectName} did not attend its own call`).toBeTruthy(); + expect(self![field]).toBe(`${objectName}_1`); + } + }); + + it('invents no attendee for a company, a deal or a ticket', async () => { + // An account is not a person. The event's `related_to_*` link already + // records what the call was about; a fabricated attendee row would be a + // record asserting somebody was in the room. + for (const objectName of ['crm_account', 'crm_opportunity', 'crm_case']) { + const { engine } = await run(objectName, 'log_call'); + const attendees = engine.inserted('crm_event_attendee'); + expect(attendees, `${objectName} invented attendees`).toHaveLength(1); + expect(attendees[0]!.is_organizer).toBe(true); + } + }); + + it('does not write the same person twice', async () => { + const { engine } = await run('crm_contact', 'log_meeting', { + // The contact the action fired from, named AGAIN in the picker, plus the + // acting user naming themselves. + input: { subject: 'Sync', attendee_contacts: ['crm_contact_1'], attendee_users: ['usr_1'] }, }); - expect(doc.record_label).toBe('CASE-00042 - Printer offline'); - expect(doc.object_name).toBe('crm_case'); - expect(doc.record_id).toBe('case_1'); + expect(engine.inserted('crm_event_attendee')).toHaveLength(2); }); +}); + +// ─────────────────────────────────────────── the event row itself ── + +describe('every activity action writes a real crm_event', () => { + it.each(TARGETS)('%s links the event back to the record it fired from', async (objectName) => { + const { engine } = await run(objectName, 'log_call', { input: { subject: 'Intro', duration: 15 } }); + const [event] = engine.inserted('crm_event') as AnyRec[]; + expect(event, `${objectName} wrote no crm_event`).toBeTruthy(); + expect(event.related_to_type).toBe(objectName); + expect(event[ACTIVITY_TARGETS[objectName]!]).toBe(`${objectName}_1`); + expect(event.owner).toBe('usr_1'); + expect(event.duration_minutes).toBe(15); + expect(event.start_datetime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('a logged call is `held`; a scheduled meeting is `planned`', async () => { + const logged = (await run('crm_opportunity', 'log_call')).engine.inserted('crm_event')[0]!; + expect(logged.status).toBe('held'); + expect(logged.type).toBe('call'); - it.each(TWINS)('%s writes null rather than throwing when the label field is absent', async (name) => { - const doc = await loggedActivity(name, { + const booked = (await run('crm_opportunity', 'schedule_meeting', { + input: { subject: 'Deep dive', start_date: '2026-09-01', start_time: '09:00', location: 'Zoom' }, + })).engine.inserted('crm_event')[0]!; + // The distinction the whole churn signal rests on: a booking must not reset + // the customer's recency clock (`event.hook.ts` gates its bubble on `held`). + expect(booked.status).toBe('planned'); + expect(booked.type).toBe('meeting'); + expect(booked.start_datetime).toBe('2026-09-01T09:00:00.000Z'); + expect(booked.location).toBe('Zoom'); + }); + + it('falls back to now for an unparseable start rather than writing NaN', async () => { + const { engine } = await run('crm_lead', 'schedule_meeting', { + input: { subject: 'Deep dive', start_date: 'next tuesday-ish' }, + }); + const [event] = engine.inserted('crm_event') as AnyRec[]; + expect(event.start_datetime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(new Date(event.start_datetime).getTime()).not.toBeNaN(); + }); + + it('the timeline row points at the event (ADR-0052), not at a blob', async () => { + const { engine, result } = await run('crm_account', 'log_meeting', { + input: { subject: 'QBR', duration: 45, notes: 'Renewal discussed' }, + }); + const [activity] = engine.inserted('sys_activity') as AnyRec[]; + expect(activity.source_object).toBe('crm_event'); + expect(activity.source_id).toBe(result.eventId); + const meta = JSON.parse(activity.metadata); + expect(meta.kind).toBe('meeting'); + expect(meta.duration_minutes).toBe(45); + expect(meta.notes).toBe('Renewal discussed'); + // A COUNT is a display hint; the attendees themselves are rows. + expect(meta.attendee_count).toBe(engine.inserted('crm_event_attendee').length); + expect(meta.attendees).toBeUndefined(); + }); +}); + +// ──────────────────────────── record_label still resolves (#514 item 2) ── + +describe('activity actions stamp a real record_label (#514 item 2)', () => { + it('the codebase still makes this worth guarding — most nameFields are not `name`', () => { + // If this ever drops to zero the guards below become vacuous: reading + // `record.name` would be right everywhere and the bug could not recur. + const withNameField = stackObjects.filter((o) => typeof o.nameField === 'string'); + const notName = withNameField.filter((o) => o.nameField !== 'name'); + expect(withNameField.length, 'no object declares a nameField').toBeGreaterThan(0); + expect(notName.length).toBeGreaterThan(withNameField.length / 2); + }); + + it.each(TARGETS)('%s resolves its own declared nameField', async (objectName) => { + const nameField = objectByName.get(objectName)?.nameField; + expect(nameField, `${objectName} declares no nameField`).toBeTruthy(); + for (const kind of KINDS) { + const { engine } = await run(objectName, kind, { + input: { subject: 'Quarterly sync', start_date: '2026-09-01', start_time: '09:00' }, + }); + const [activity] = engine.inserted('sys_activity') as AnyRec[]; + expect(activity.record_label, `${objectName}:${kind}`).toBe(`label of ${objectName}`); + expect(activity.object_name).toBe(objectName); + expect(activity.record_id).toBe(`${objectName}_1`); + } + }); + + it('writes null rather than throwing when the label field is absent', async () => { + const engine = makeSandboxEngine(); + const { result } = await runActionBody(action('crm_case', 'log_call'), { objectName: 'crm_case', record: { id: 'case_1' }, input: { subject: 'Follow-up with customer' }, + engine, }); - expect(doc.record_label).toBeNull(); + expect(result.activityId).toBeTruthy(); + expect((engine.inserted('sys_activity')[0] as AnyRec).record_label).toBeNull(); + }); + + it('carries the acting user onto the activity', async () => { + const { engine } = await run('crm_lead', 'log_call'); + const [activity] = engine.inserted('sys_activity') as AnyRec[]; + expect(activity.actor_id).toBe('usr_1'); + expect(activity.actor_name).toBe('Ada Lovelace'); + expect(activity.type).toBe('completed'); }); - it.each(TWINS)('%s survives an unknown object with no declared nameField', async (name) => { - const doc = await loggedActivity(name, { - objectName: 'not_a_registered_object', - record: { id: 'x_1' }, - input: { subject: 'Follow-up' }, + it('marks a booking as scheduled, not completed', async () => { + const { engine } = await run('crm_lead', 'schedule_meeting', { + input: { subject: 'Deep dive', start_date: '2026-09-01', start_time: '09:00' }, }); - expect(doc.record_label).toBeNull(); + expect((engine.inserted('sys_activity')[0] as AnyRec).type).toBe('scheduled'); }); }); -describe('log_call and log_meeting stay twins (#514 item 15)', () => { - const paramsOf = (name: string): AnyRec[] => action(name).params ?? []; - const param = (actionName: string, paramName: string): AnyRec => { - const p = paramsOf(actionName).find((x) => x.name === paramName); - if (!p) throw new Error(`${actionName} declares no "${paramName}" param`); +// ─────────────────────────────────── the family stays a family (#514/15) ── + +describe('the activity actions stay twins (#514 item 15)', () => { + const paramsOf = (objectName: string, kind: string): AnyRec[] => action(objectName, kind).params ?? []; + const param = (objectName: string, kind: string, name: string): AnyRec => { + const p = paramsOf(objectName, kind).find((x) => x.name === name); + if (!p) throw new Error(`${objectName}:${kind} declares no "${name}" param`); return p; }; it('agrees on whether duration is required — and it is not', () => { // The asymmetry this pins: `duration` was required for calls and optional - // for meetings, undocumented either way. Optional on both is what the + // for meetings, undocumented either way. Optional everywhere is what the // shared body is written for (its `duration ? … : subject` summary branch // is dead while the field is mandatory). - for (const name of TWINS) { - expect(param(name, 'duration').required, `${name}.duration requiredness`).toBe(false); + for (const objectName of TARGETS) { + for (const kind of KINDS) { + expect(param(objectName, kind, 'duration').required, `${objectName}:${kind}`).toBe(false); + } } }); - it('agrees on the shared subject/notes core', () => { - expect(param('log_call', 'subject').required).toBe(true); - expect(param('log_meeting', 'subject').required).toBe(true); - expect(param('log_call', 'notes').required).toBe(false); - expect(param('log_meeting', 'notes').required).toBe(false); - for (const [a, b] of [ - ['log_call', 'log_meeting'], - ['log_meeting', 'log_call'], - ] as const) { - expect(param(a, 'subject').type).toBe(param(b, 'subject').type); - expect(param(a, 'notes').type).toBe(param(b, 'notes').type); - expect(param(a, 'duration').type).toBe(param(b, 'duration').type); + it('agrees on the shared subject / attendee / notes core', () => { + for (const objectName of TARGETS) { + for (const kind of KINDS) { + expect(param(objectName, kind, 'subject').required).toBe(true); + expect(param(objectName, kind, 'notes').required).toBe(false); + expect(param(objectName, kind, 'attendee_contacts')).toMatchObject({ + type: 'lookup', reference: 'crm_contact', multiple: true, required: false, + }); + expect(param(objectName, kind, 'attendee_users')).toMatchObject({ + type: 'lookup', reference: 'sys_user', multiple: true, required: false, + }); + } } }); - it('attendees is the only param the meeting adds', () => { - const callParams = paramsOf('log_call').map((p) => p.name); - const meetingParams = paramsOf('log_meeting').map((p) => p.name); - expect(meetingParams.filter((p) => !callParams.includes(p))).toEqual(['attendees']); - expect(callParams.filter((p) => !meetingParams.includes(p))).toEqual([]); + it('the schedule variant is the only one that collects a start time', () => { + for (const objectName of TARGETS) { + const names = (kind: string) => paramsOf(objectName, kind).map((p) => p.name); + expect(names('schedule_meeting').filter((n) => !names('log_call').includes(n))) + .toEqual(['start_date', 'start_time', 'location']); + expect(names('log_meeting')).toEqual(names('log_call')); + expect(param(objectName, 'schedule_meeting', 'start_date').required).toBe(true); + expect(param(objectName, 'schedule_meeting', 'start_time').required).toBe(true); + } }); it('shares every dispatch-level declaration except icon and labels', () => { - const call = action('log_call'); - const meeting = action('log_meeting'); - for (const key of ['type', 'objectName', 'refreshAfter'] as const) { - expect(meeting[key], `log_meeting.${key}`).toEqual(call[key]); + for (const objectName of TARGETS) { + const call = action(objectName, 'log_call'); + for (const kind of ['log_meeting', 'schedule_meeting']) { + const other = action(objectName, kind); + for (const key of ['type', 'objectName', 'refreshAfter'] as const) { + expect(other[key], `${objectName}:${kind}.${key}`).toEqual(call[key]); + } + expect(other.locations).toEqual(call.locations); + expect(other.body?.capabilities).toEqual(call.body?.capabilities); + expect(other.body?.timeoutMs).toEqual(call.body?.timeoutMs); + } } - expect(meeting.locations).toEqual(call.locations); - expect(meeting.body?.capabilities).toEqual(call.body?.capabilities); - expect(meeting.body?.timeoutMs).toEqual(call.body?.timeoutMs); }); - it('emits the same activity row apart from the summary prefix and metadata', async () => { - const opts: ActionRunOpts = { - objectName: 'crm_case', - record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline' }, - input: { subject: 'Quarterly sync', duration: 30, notes: 'Agreed next steps' }, - }; - const call = await loggedActivity('log_call', opts); - const meeting = await loggedActivity('log_meeting', opts); + it('emits the same rows apart from the summary prefix and the event kind', async () => { + const input = { subject: 'Quarterly sync', duration: 30, notes: 'Agreed next steps' }; + const call = await run('crm_case', 'log_call', { input }); + const meeting = await run('crm_case', 'log_meeting', { input }); - const shape = (doc: AnyRec) => { - const { summary, metadata, ...rest } = doc; + const shape = (engine: ReturnType) => { + const row = engine.inserted('sys_activity')[0] as AnyRec; + const { summary, metadata, source_id, ...rest } = row; return rest; }; - expect(shape(meeting)).toEqual(shape(call)); - - expect(call.summary).toBe('Quarterly sync (30 min)'); - expect(meeting.summary).toBe('Meeting: Quarterly sync (30 min)'); - - const callMeta = JSON.parse(call.metadata); - const meetingMeta = JSON.parse(meeting.metadata); - expect(callMeta.kind).toBe('call'); - expect(meetingMeta.kind).toBe('meeting'); - // The shared metadata core must agree; only the per-kind extras differ. - for (const key of ['duration_minutes', 'notes'] as const) { - expect(meetingMeta[key], `metadata.${key}`).toEqual(callMeta[key]); + expect(shape(meeting.engine)).toEqual(shape(call.engine)); + + expect((call.engine.inserted('sys_activity')[0] as AnyRec).summary).toBe('Quarterly sync (30 min)'); + expect((meeting.engine.inserted('sys_activity')[0] as AnyRec).summary).toBe('Meeting: Quarterly sync (30 min)'); + expect((call.engine.inserted('crm_event')[0] as AnyRec).type).toBe('call'); + expect((meeting.engine.inserted('crm_event')[0] as AnyRec).type).toBe('meeting'); + }); + + it('drops the duration suffix when duration is omitted', async () => { + for (const kind of LOGGING_KINDS) { + const { engine } = await run('crm_case', kind); + const activity = engine.inserted('sys_activity')[0] as AnyRec; + expect(activity.summary).not.toMatch(/min\)/); + expect(activity.summary.endsWith('Quarterly sync')).toBe(true); + expect(JSON.parse(activity.metadata).duration_minutes).toBe(0); + // …and no zero-length event is written either. + expect((engine.inserted('crm_event')[0] as AnyRec).duration_minutes).toBeUndefined(); } - expect(callMeta.direction).toBe('outbound'); - expect(meetingMeta.attendees).toBe(''); }); +}); - it.each(TWINS)('%s drops the duration suffix when duration is omitted', async (name) => { - // Reachable only because duration is optional on both — the branch this - // asserts was dead code for log_call while the param was required. - const doc = await loggedActivity(name, { - objectName: 'crm_case', - record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline' }, - input: { subject: 'Quarterly sync' }, +// ────────────────────── the Console can actually submit it (objectstack#5061) ── + +describe('schedule_meeting is submittable from the Console (objectstack#5061)', () => { + /** + * The dogfood verification of PR #670 found `schedule_meeting` unusable from + * the UI, from BOTH entry points: `start` was declared `type: 'datetime'`, + * which the Console renders as a zone-less `` + * and POSTs raw — and the runtime's action-param validator answers 400, + * `expected an ISO-8601 instant with explicit zone`. The renderer's output + * shape and the validator's accepted shape did not intersect, so no user + * input could pass. Filed upstream as objectstack-ai/objectstack#5061; the + * app's workaround is a `date` + `time` PAIR, joined in the body. + * + * These run the EXACT bag the Console produces through the SAME + * `validateActionParams` the dispatcher rejects with, and then through the + * real QuickJS body — because a shape that passes one and not the other is + * precisely the defect being worked around. + */ + + /** The bag the Console POSTs, verbatim: zone-less strings from native pickers. */ + const CONSOLE_BAG = { + subject: 'Q3 roadmap review', + start_date: '2026-08-10', + start_time: '15:00', + duration: 45, + location: 'Zoom', + attendee_contacts: ['con_1', 'con_2'], + attendee_users: ['usr_7'], + notes: 'Walk through the rollout plan', + }; + + /** What the dispatcher merges in on top of the submitted params. */ + const dispatchExtras = (objectName: string) => ({ + recordId: `${objectName}_1`, + objectName, + }); + + const declaredParams = (objectName: string) => + (action(objectName, 'schedule_meeting').params ?? []).map((p: AnyRec) => ({ + name: p.name, + type: p.type, + multiple: p.multiple, + required: p.required, + options: p.options, + })); + + it.each(TARGETS)('%s accepts the console-produced bag with no validation issue', (objectName) => { + const issues = validateActionParams( + declaredParams(objectName) as never, + { ...CONSOLE_BAG, ...dispatchExtras(objectName) }, + ); + expect( + issues, + `the Console cannot submit ${objectName}:schedule_meeting:\n ` + + issues.map((i) => `${i.param}: ${i.message}`).join('\n '), + ).toEqual([]); + }); + + it('the shape this replaced is still rejected — the workaround is not decorative', () => { + // If this ever passes, objectstack#5061 has been fixed on the platform and + // the pair can collapse back to one `type: 'datetime'` param (see the + // REVERT note in src/actions/global.actions.ts). + const issues = validateActionParams( + [{ name: 'start', type: 'datetime', required: true }], + { start: '2026-08-10T15:00' }, + ); + expect(issues.map((i) => i.code)).toEqual(['invalid_shape']); + expect(issues[0]!.message).toMatch(/explicit zone/); + }); + + it('a bare wall clock would not pass as a datetime either way round', () => { + // Both halves are legal on their own declared type, and neither is a legal + // instant — which is why the join has to happen inside the body. + expect(validateActionParams( + [{ name: 'start_date', type: 'date', required: true }, + { name: 'start_time', type: 'time', required: true }], + { start_date: '2026-08-10', start_time: '15:00' }, + )).toEqual([]); + expect(validateActionParams( + [{ name: 'start', type: 'datetime', required: true }], + { start: '2026-08-10' }, + )).not.toEqual([]); + }); + + it('the console bag writes a planned event at the joined UTC instant, with attendees', async () => { + const engine = makeSandboxEngine(); + const { result } = await runActionBody(action('crm_opportunity', 'schedule_meeting'), { + objectName: 'crm_opportunity', + record: recordFor('crm_opportunity'), + input: CONSOLE_BAG, + engine, + }); + + const [event] = engine.inserted('crm_event') as AnyRec[]; + expect(event.status).toBe('planned'); + expect(event.type).toBe('meeting'); + // 15:00 is read as UTC — the only zone this body can apply deterministically + // (the sandbox ctx carries no user/org timezone), and the one both param + // labels state. + expect(event.start_datetime).toBe('2026-08-10T15:00:00.000Z'); + expect(event.duration_minutes).toBe(45); + expect(event.location).toBe('Zoom'); + expect(event.related_to_opportunity).toBe('crm_opportunity_1'); + + // organiser + two contacts + one colleague, as rows (#592 acceptance) + const attendees = engine.inserted('crm_event_attendee'); + expect(attendees).toHaveLength(4); + expect(attendees.every((a) => a.crm_event === result.eventId)).toBe(true); + expect(attendees.filter((a) => a.attendee_type === 'contact').map((a) => a.crm_contact).sort()) + .toEqual(['con_1', 'con_2']); + }); + + it('accepts a seconds-bearing wall clock, which the validator also allows', async () => { + const { engine } = await run('crm_lead', 'schedule_meeting', { + input: { subject: 'Deep dive', start_date: '2026-08-10', start_time: '15:00:30' }, }); - expect(doc.summary).not.toMatch(/min\)/); - expect(doc.summary.endsWith('Quarterly sync')).toBe(true); - expect(JSON.parse(doc.metadata).duration_minutes).toBe(0); + expect((engine.inserted('crm_event')[0] as AnyRec).start_datetime).toBe('2026-08-10T15:00:30.000Z'); }); - it.each(TWINS)('%s carries the acting user onto the activity', async (name) => { - const doc = await loggedActivity(name, { - objectName: 'crm_case', - record: { id: 'case_1', display_title: 'CASE-00042 - Printer offline' }, - input: { subject: 'Quarterly sync' }, + it('booking through the console still does NOT bump recency', async () => { + // The event the console bag produces, handed to the REAL recency hook. A + // booking that refreshed the customer's clock is how `at_risk_accounts` + // learns to lie, and the fixed param shape must not have changed that. + const engine = makeSandboxEngine(); + await runActionBody(action('crm_opportunity', 'schedule_meeting'), { + objectName: 'crm_opportunity', + record: recordFor('crm_opportunity'), + input: CONSOLE_BAG, + engine, + }); + const [event] = engine.inserted('crm_event') as AnyRec[]; + + const bubble = hookNamed(eventHooks, 'event_activity_bubble'); + const h = makeHarness({ + crm_account: [{ id: 'acc1' }], + crm_opportunity: [{ id: 'crm_opportunity_1', crm_account: 'acc1' }], }); - expect(doc.actor_id).toBe('usr_1'); - expect(doc.actor_name).toBe('Ada Lovelace'); - expect(doc.type).toBe('completed'); + await bubble.handler(makeCtx({ + event: 'afterInsert', + input: { id: 'evt_1', ...event }, + user: { id: 'usr_1' }, + api: h.api, + })); + expect(h.calls, 'a booking bumped interaction recency').toHaveLength(0); + expect(h.rows('crm_account')[0]!.last_activity_date).toBeUndefined(); }); }); diff --git a/test/hook-write-shape.test.ts b/test/hook-write-shape.test.ts index 2d6f620e..37e00970 100644 --- a/test/hook-write-shape.test.ts +++ b/test/hook-write-shape.test.ts @@ -254,11 +254,22 @@ const CASES: Record = { 'task_activity_bubble — the activity bubble on the parent record': { hook: 'task_activity_bubble', event: 'afterUpdate', - input: { id: 'task_1', related_to_type: 'crm_account', related_to_account: 'acc_1' }, + // The COMPLETING transition is what bubbles now (#592): an open task is a + // promise, not an interaction, and the hook used to fire on any edit. + input: { id: 'task_1', status: 'completed', related_to_account: 'acc_1' }, previous: { id: 'task_1', status: 'in_progress' }, seed: { crm_account: [{ id: 'acc_1' }] }, writes: [{ object: 'crm_account', id: 'acc_1', doc: { last_activity_date: today } }], }, + + 'event_activity_bubble — interaction recency from a held event': { + hook: 'event_activity_bubble', + event: 'afterUpdate', + input: { id: 'evt_1', status: 'held', related_to_account: 'acc_1' }, + previous: { id: 'evt_1', status: 'planned' }, + seed: { crm_account: [{ id: 'acc_1' }] }, + writes: [{ object: 'crm_account', id: 'acc_1', doc: { last_activity_date: today } }], + }, }; describe('every hook-side derived write reaches the engine in the engine’s own shape', () => { diff --git a/test/hooks-runtime-service.test.ts b/test/hooks-runtime-service.test.ts index 8d62ffa5..c553878b 100644 --- a/test/hooks-runtime-service.test.ts +++ b/test/hooks-runtime-service.test.ts @@ -890,68 +890,99 @@ describe('task_recurrence', () => { describe('task_activity_bubble', () => { const hook = hookNamed(taskHooks, 'task_activity_bubble'); + /** A completing task, which is the only transition that bubbles (#592). */ + const completing = (extra: Rec) => ({ + event: 'afterUpdate', + input: { id: 't1', status: 'completed', ...extra }, + previous: { id: 't1', status: 'in_progress' }, + user: USER, + }); + it('bubbles last_activity_date to a related account', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); + await hook.handler(makeCtx({ ...completing({ related_to_account: 'acc1' }), api: h.api })); + expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + }); + + it('uses last_contacted_date for a lead, which has no last_activity_date', async () => { + const h = makeHarness({ crm_lead: [{ id: 'l1' }] }); + await hook.handler(makeCtx({ ...completing({ related_to_lead: 'l1' }), api: h.api })); + const lead = h.rows('crm_lead')[0]; + expect(lead.last_activity_date, 'crm_lead has no last_activity_date column').toBeUndefined(); + expect(typeof lead.last_contacted_date).toBe('string'); + }); + + it('does not bubble while the task is still open — a promise is not contact', async () => { const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); await hook.handler(makeCtx({ event: 'afterUpdate', - input: { id: 't1', related_to_type: 'crm_account', related_to_account: 'acc1' }, - previous: { id: 't1' }, + input: { id: 't1', status: 'in_progress', related_to_account: 'acc1' }, + previous: { id: 't1', status: 'not_started' }, user: USER, api: h.api, })); - expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + expect(h.calls.filter((c) => c.op === 'update')).toHaveLength(0); }); - it('uses last_contacted_date for a lead, which has no last_activity_date', async () => { - const h = makeHarness({ crm_lead: [{ id: 'l1' }] }); + it('does not bubble twice for a task that was already completed', async () => { + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); await hook.handler(makeCtx({ event: 'afterUpdate', - input: { id: 't1', related_to_type: 'crm_lead', related_to_lead: 'l1' }, - previous: { id: 't1' }, + input: { id: 't1', status: 'completed', related_to_account: 'acc1', subject: 'edited' }, + previous: { id: 't1', status: 'completed' }, user: USER, api: h.api, })); - const lead = h.rows('crm_lead')[0]; - expect(lead.last_activity_date, 'crm_lead has no last_activity_date column').toBeUndefined(); - expect(typeof lead.last_contacted_date).toBe('string'); + expect(h.calls.filter((c) => c.op === 'update')).toHaveLength(0); + }); + + it('walks UP to the account from a contact, an opportunity and a case (#592)', async () => { + // The defect this closes: a rep completes their work on the OPPORTUNITY, + // never on the account row, so bubbling to the named record alone left + // `crm_account.last_activity_date` untouched through a whole sales cycle + // and `at_risk_accounts` listed the busiest customers in the book. + for (const [field, object] of [ + ['related_to_contact', 'crm_contact'], + ['related_to_opportunity', 'crm_opportunity'], + ['related_to_case', 'crm_case'], + ] as const) { + const h = makeHarness({ + crm_account: [{ id: 'acc1' }], + [object]: [{ id: 'x1', crm_account: 'acc1' }], + }); + await hook.handler(makeCtx({ ...completing({ [field]: 'x1' }), api: h.api })); + expect(h.rows('crm_account')[0].last_activity_date, `${object} did not reach its account`) + .toBe(today()); + } }); - it.each(['crm_contact', 'crm_opportunity', 'crm_case'])( - 'writes nothing for %s, which carries no activity timestamp', async (type) => { - const h = makeHarness({ [type]: [{ id: 'x1' }] }); - const refField = { - crm_contact: 'related_to_contact', - crm_opportunity: 'related_to_opportunity', - crm_case: 'related_to_case', - }[type]!; - await hook.handler(makeCtx({ - event: 'afterUpdate', - input: { id: 't1', related_to_type: type, [refField]: 'x1' }, - previous: { id: 't1' }, - user: USER, - api: h.api, - })); - expect(h.calls).toHaveLength(0); - }, - ); + it('stamps the contact itself as well as its account', async () => { + const h = makeHarness({ + crm_account: [{ id: 'acc1' }], + crm_contact: [{ id: 'c1', crm_account: 'acc1' }], + }); + await hook.handler(makeCtx({ ...completing({ related_to_contact: 'c1' }), api: h.api })); + expect(typeof h.rows('crm_contact')[0].last_contacted_date).toBe('string'); + expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + }); + + it('no longer needs related_to_type to be set', async () => { + // It is a display hint a rep can leave blank, and while the bubble keyed + // off it a task with a perfectly good related_to_account bubbled nowhere. + const h = makeHarness({ crm_account: [{ id: 'acc1' }] }); + await hook.handler(makeCtx({ ...completing({ related_to_account: 'acc1' }), api: h.api })); + expect(h.rows('crm_account')[0].last_activity_date).toBe(today()); + }); it('is a no-op with no parent, and never propagates a write failure', async () => { const h = makeHarness({}); - await hook.handler(makeCtx({ - event: 'afterUpdate', input: { id: 't1' }, previous: { id: 't1' }, user: USER, api: h.api, - })); + await hook.handler(makeCtx({ ...completing({}), api: h.api })); expect(h.calls).toHaveLength(0); // A denied write must be swallowed — the bubble is best-effort and must // never break the parent task write. await expect( - hook.handler(makeCtx({ - event: 'afterUpdate', - input: { id: 't1', related_to_type: 'crm_account', related_to_account: 'acc1' }, - previous: { id: 't1' }, - user: USER, - api: makeDeniedApi('denied'), - })), + hook.handler(makeCtx({ ...completing({ related_to_account: 'acc1' }), api: makeDeniedApi('denied') })), ).resolves.toBeUndefined(); }); }); diff --git a/test/metadata-references.test.ts b/test/metadata-references.test.ts index dd9924d1..5d5ab042 100644 --- a/test/metadata-references.test.ts +++ b/test/metadata-references.test.ts @@ -450,8 +450,12 @@ describe('navigation reaches everything the app ships', () => { .map((o) => o.name) .filter((name: string) => // Line items and junctions are edited inside their parent, never - // reached on their own. - !/_line_item$|_member$/.test(name) && !reachable.has(name)); + // reached on their own. `_attendee` joined the list with `crm_event` + // (#592): an attendee row is reached through the meeting it belongs to, + // and it is `controlled_by_parent`, so a nav entry would offer a list + // whose every row derives its visibility from a record you got to some + // other way. + !/_line_item$|_member$|_attendee$/.test(name) && !reachable.has(name)); expect(stranded, `objects with no navigation entry:\n ${stranded.join('\n ')}`).toEqual([]); }); diff --git a/test/runtime-coverage.test.ts b/test/runtime-coverage.test.ts index 9d352add..83e53773 100644 --- a/test/runtime-coverage.test.ts +++ b/test/runtime-coverage.test.ts @@ -40,6 +40,10 @@ const RUNTIME_TEST_FILES = [ 'flow-record-change.test.ts', 'flow-case-actions.test.ts', 'flow-campaign-enrollment.test.ts', + // #592 — the activity model's own runtime file: both `crm_event` hooks, the + // parity check that keeps the duplicated bubble body from drifting, and the + // readonly-strip regression proof against a real engine. + 'activity-recency.test.ts', ]; /** diff --git a/test/sharing-coverage.test.ts b/test/sharing-coverage.test.ts index d3ea36cd..db760092 100644 --- a/test/sharing-coverage.test.ts +++ b/test/sharing-coverage.test.ts @@ -67,6 +67,14 @@ const ACCOUNT_CHILD_COVERAGE: Record crm_quote: 'own_only', crm_contract: 'own_only', crm_task: 'own_only', + // #592. `crm_event` is `private` with no sharing rule of its own, exactly + // like `crm_task` — the two are the same kind of record (a rep's personal + // activity) and there is no reason for one to reach further than the + // other. A rep who receives an account through a territory rule therefore + // sees the account's meetings only where they own them. Widening that is + // the same open business decision #549 asks about tasks, and it belongs in + // the PR that answers it for the whole family, not in this one. + crm_event: 'own_only', }; /**