@clivly/core 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,7 @@ client.
48
48
  | Entry | Contents |
49
49
  | --- | --- |
50
50
  | `@clivly/core` | The re-exported surface: contracts, entity config, mapping form, sync engine, view compiler, projections, shared types |
51
- | `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities). List reads are keyset-paged: `listContactsPage`, `listCompaniesPage`, `listDealsPage` and `listTasksPage` take a `PageRequest` and return a `Page` |
51
+ | `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities). List reads are keyset-paged: `listContactsPage`, `listCompaniesPage`, `listDealsPage` and `listTasksPage` take a `PageRequest` and return a `Page`; `countContacts` returns how many contacts match the same `ContactFilters` |
52
52
  | `@clivly/core/auth-adapter` | `ClivlyAuthAdapter`, `ClivlyUser`, `authCustom()`, `MembershipResolver`, `CrmMembership` — pluggable host-app identity + the `crm_members` authorization layer |
53
53
  | `@clivly/core/entity-config` | `defineClivlyConfig`, `validateEntitiesConfig`, `entitiesConfigSchema`, `CANONICAL_FIELDS`, `CRM_CONCEPTS`, `ClivlyConfigError`, and the config types |
54
54
  | `@clivly/core/types` | Shared domain types (`Contact`, `Company`, `Deal`, `Task`, `Note`, `Activity`, inputs, filters, `CrmRole`) |
@@ -19,6 +19,20 @@ export interface CRMAdapter {
19
19
  archiveContact(orgId: string, id: string): Promise<Contact>;
20
20
  archiveTask(orgId: string, id: string): Promise<Task>;
21
21
  completeTask(orgId: string, id: string): Promise<Task>;
22
+ /**
23
+ * How many contacts match `filters`, ignoring pagination entirely.
24
+ *
25
+ * Exists so a caller can state the size of a selection before acting on it —
26
+ * a bulk send has to say "this will go to N contacts", and the paged read
27
+ * cannot answer that: it knows only whether *another* page exists, never how
28
+ * many rows are behind it.
29
+ *
30
+ * Takes the same `ContactFilters` as {@link CRMAdapter.listContactsPage} and
31
+ * applies exactly the same predicates, so the count and the list can never
32
+ * disagree about what matches. It returns a number rather than rows, so it
33
+ * stays bounded however large the match is.
34
+ */
35
+ countContacts(orgId: string, filters?: ContactFilters): Promise<number>;
22
36
  createActivity(orgId: string, data: CreateActivityInput): Promise<Activity>;
23
37
  createCompany(orgId: string, data: CreateCompanyInput): Promise<Company>;
24
38
  createContact(orgId: string, data: CreateContactInput): Promise<Contact>;
@@ -19,6 +19,20 @@ export interface CRMAdapter {
19
19
  archiveContact(orgId: string, id: string): Promise<Contact>;
20
20
  archiveTask(orgId: string, id: string): Promise<Task>;
21
21
  completeTask(orgId: string, id: string): Promise<Task>;
22
+ /**
23
+ * How many contacts match `filters`, ignoring pagination entirely.
24
+ *
25
+ * Exists so a caller can state the size of a selection before acting on it —
26
+ * a bulk send has to say "this will go to N contacts", and the paged read
27
+ * cannot answer that: it knows only whether *another* page exists, never how
28
+ * many rows are behind it.
29
+ *
30
+ * Takes the same `ContactFilters` as {@link CRMAdapter.listContactsPage} and
31
+ * applies exactly the same predicates, so the count and the list can never
32
+ * disagree about what matches. It returns a number rather than rows, so it
33
+ * stays bounded however large the match is.
34
+ */
35
+ countContacts(orgId: string, filters?: ContactFilters): Promise<number>;
22
36
  createActivity(orgId: string, data: CreateActivityInput): Promise<Activity>;
23
37
  createCompany(orgId: string, data: CreateCompanyInput): Promise<Company>;
24
38
  createContact(orgId: string, data: CreateContactInput): Promise<Contact>;
package/dist/index.cjs CHANGED
@@ -33,6 +33,18 @@ function computeDealValue(source, fields, mappedFields, stageType) {
33
33
  const seats = Number(rawSeats);
34
34
  if (!Number.isSafeInteger(seats)) return null;
35
35
  const monthly = (source.baseFeeCents ?? 0) + seats * unitPriceCents;
36
+ if (!Number.isSafeInteger(monthly) || monthly < 0) return null;
37
+ return normalizePrice(monthly, source, fields, mappedFields);
38
+ }
39
+ /** Preserve old configurations while making new price bases explicit. */
40
+ function normalizePrice(monthly, source, fields, mappedFields) {
41
+ if (source.priceBasis === "monthly") return monthly;
42
+ if (source.priceBasis === "billing_interval") {
43
+ const interval = source.intervalField && mappedFields.has(source.intervalField) ? fields[source.intervalField]?.trim().toLowerCase() : null;
44
+ if (interval === "month" || interval === "monthly") return monthly;
45
+ if (interval === "year" || interval === "annual" || interval === "annually" || interval === "yearly") return Math.round(monthly / MONTHS_PER_YEAR);
46
+ return null;
47
+ }
36
48
  return source.intervalField !== void 0 && mappedFields.has(source.intervalField) && fields[source.intervalField] === "year" ? Math.round(monthly / MONTHS_PER_YEAR) : monthly;
37
49
  }
38
50
  //#endregion
package/dist/index.d.cts CHANGED
@@ -17,7 +17,7 @@ import { EntitySyncResult, MirrorRecord, PersistAction, ResolvedMembership, Sour
17
17
  * D8 made for stage-rule conditions.
18
18
  */
19
19
  interface PerSeatValueSource {
20
- /** Flat monthly fee in cents, added before the interval is normalised. */
20
+ /** Flat fee in cents, using the same price basis as the plan prices. */
21
21
  baseFeeCents?: number;
22
22
  /** Mapped column holding "month" or "year". Absent means always monthly. */
23
23
  intervalField?: string;
@@ -26,6 +26,8 @@ interface PerSeatValueSource {
26
26
  planField: string;
27
27
  /** Unit price in cents, keyed by the value found in `planField`. */
28
28
  plans: Record<string, number>;
29
+ /** Monthly rates or amounts charged each billing period. Omitted preserves legacy behaviour. */
30
+ priceBasis?: "monthly" | "billing_interval";
29
31
  /** Mapped column holding the seat count. */
30
32
  seatField: string;
31
33
  /** Stage types whose deals are worth nothing (usually `["lost"]`). */
package/dist/index.d.mts CHANGED
@@ -17,7 +17,7 @@ import { EntitySyncResult, MirrorRecord, PersistAction, ResolvedMembership, Sour
17
17
  * D8 made for stage-rule conditions.
18
18
  */
19
19
  interface PerSeatValueSource {
20
- /** Flat monthly fee in cents, added before the interval is normalised. */
20
+ /** Flat fee in cents, using the same price basis as the plan prices. */
21
21
  baseFeeCents?: number;
22
22
  /** Mapped column holding "month" or "year". Absent means always monthly. */
23
23
  intervalField?: string;
@@ -26,6 +26,8 @@ interface PerSeatValueSource {
26
26
  planField: string;
27
27
  /** Unit price in cents, keyed by the value found in `planField`. */
28
28
  plans: Record<string, number>;
29
+ /** Monthly rates or amounts charged each billing period. Omitted preserves legacy behaviour. */
30
+ priceBasis?: "monthly" | "billing_interval";
29
31
  /** Mapped column holding the seat count. */
30
32
  seatField: string;
31
33
  /** Stage types whose deals are worth nothing (usually `["lost"]`). */
package/dist/index.mjs CHANGED
@@ -32,6 +32,18 @@ function computeDealValue(source, fields, mappedFields, stageType) {
32
32
  const seats = Number(rawSeats);
33
33
  if (!Number.isSafeInteger(seats)) return null;
34
34
  const monthly = (source.baseFeeCents ?? 0) + seats * unitPriceCents;
35
+ if (!Number.isSafeInteger(monthly) || monthly < 0) return null;
36
+ return normalizePrice(monthly, source, fields, mappedFields);
37
+ }
38
+ /** Preserve old configurations while making new price bases explicit. */
39
+ function normalizePrice(monthly, source, fields, mappedFields) {
40
+ if (source.priceBasis === "monthly") return monthly;
41
+ if (source.priceBasis === "billing_interval") {
42
+ const interval = source.intervalField && mappedFields.has(source.intervalField) ? fields[source.intervalField]?.trim().toLowerCase() : null;
43
+ if (interval === "month" || interval === "monthly") return monthly;
44
+ if (interval === "year" || interval === "annual" || interval === "annually" || interval === "yearly") return Math.round(monthly / MONTHS_PER_YEAR);
45
+ return null;
46
+ }
35
47
  return source.intervalField !== void 0 && mappedFields.has(source.intervalField) && fields[source.intervalField] === "year" ? Math.round(monthly / MONTHS_PER_YEAR) : monthly;
36
48
  }
37
49
  //#endregion
@@ -346,6 +346,7 @@ exports.DEFAULT_HEARTBEAT_INTERVAL_MS = DEFAULT_HEARTBEAT_INTERVAL_MS;
346
346
  exports.INSTALL_COMMAND = INSTALL_COMMAND;
347
347
  exports.INTEGRATION_STEPS = INTEGRATION_STEPS;
348
348
  exports.INTEGRATION_STEP_LABELS = INTEGRATION_STEP_LABELS;
349
+ exports.LIVE_INTERVALS = LIVE_INTERVALS;
349
350
  exports.STEP_PREREQUISITES = STEP_PREREQUISITES;
350
351
  exports.computeIntegrationState = computeIntegrationState;
351
352
  exports.deriveLiveness = deriveLiveness;
@@ -111,6 +111,7 @@ export interface IntegrationStep {
111
111
  export declare function formatAge(ms: number): string;
112
112
  /** The cadence `createClivlySDK` uses when the host app configures none. */
113
113
  export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
114
+ export declare const LIVE_INTERVALS = 2.5;
114
115
  export type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
115
116
  /**
116
117
  * Clivly's own answer to "is this app up", obtained by asking it rather than
@@ -111,6 +111,7 @@ export interface IntegrationStep {
111
111
  export declare function formatAge(ms: number): string;
112
112
  /** The cadence `createClivlySDK` uses when the host app configures none. */
113
113
  export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
114
+ export declare const LIVE_INTERVALS = 2.5;
114
115
  export type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
115
116
  /**
116
117
  * Clivly's own answer to "is this app up", obtained by asking it rather than
@@ -341,4 +341,4 @@ function computeIntegrationState(facts) {
341
341
  };
342
342
  }
343
343
  //#endregion
344
- export { DEFAULT_HEARTBEAT_INTERVAL_MS, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
344
+ export { DEFAULT_HEARTBEAT_INTERVAL_MS, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, LIVE_INTERVALS, STEP_PREREQUISITES, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
package/dist/types.d.cts CHANGED
@@ -84,12 +84,21 @@ export interface Company {
84
84
  }
85
85
  export interface Deal {
86
86
  closeDate: Date | null;
87
+ /** The organisation this deal belongs to, when one is linked. */
88
+ companyId: string | null;
87
89
  contactId: string | null;
88
90
  createdAt: Date;
89
91
  id: string;
90
92
  name: string;
91
93
  orgId: string;
92
94
  owner: string | null;
95
+ /**
96
+ * The board this deal belongs to. NULL means the org's default pipeline: the
97
+ * sync mirror wrote deals before pipelines existed, and a deal created with
98
+ * no pipeline lands on the default. Stage keys are unique per pipeline, so
99
+ * a board must filter on this, never on stage key alone.
100
+ */
101
+ pipelineId: string | null;
93
102
  position: number | null;
94
103
  stage: DealStage;
95
104
  /**
@@ -116,6 +125,11 @@ export interface Task {
116
125
  createdAt: Date;
117
126
  createdBy: string | null;
118
127
  description: string | null;
128
+ /**
129
+ * Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
130
+ * moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
131
+ */
132
+ dueAllDay?: boolean | null;
119
133
  dueAt: Date | null;
120
134
  entityId: string | null;
121
135
  entityType: TaskEntityType | null;
@@ -203,6 +217,11 @@ export interface CreateTaskInput {
203
217
  assignee?: string;
204
218
  createdBy?: string;
205
219
  description?: string;
220
+ /**
221
+ * Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
222
+ * moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
223
+ */
224
+ dueAllDay?: boolean | null;
206
225
  dueAt?: Date;
207
226
  entityId?: string;
208
227
  entityType?: TaskEntityType;
@@ -212,6 +231,11 @@ export interface CreateTaskInput {
212
231
  export interface UpdateTaskInput {
213
232
  assignee?: string | null;
214
233
  description?: string | null;
234
+ /**
235
+ * Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
236
+ * moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
237
+ */
238
+ dueAllDay?: boolean | null;
215
239
  dueAt?: Date | null;
216
240
  entityId?: string | null;
217
241
  entityType?: TaskEntityType | null;
@@ -244,6 +268,17 @@ export interface ContactFilters {
244
268
  * row is no longer sync-provenanced.
245
269
  */
246
270
  excludeArchived?: boolean;
271
+ /**
272
+ * Case-insensitive substring match on name or email.
273
+ *
274
+ * Applied in SQL, not over an already-fetched page: a filter applied after
275
+ * paging returns a partial page and a cursor that skips the rows it dropped,
276
+ * so "page 2" of a search is not the second page of the matches. An empty or
277
+ * whitespace-only value is ignored rather than matching nothing.
278
+ *
279
+ * LIKE metacharacters in the term are escaped and matched literally — a
280
+ * search for `%` finds contacts containing a percent sign, not every row.
281
+ */
247
282
  search?: string;
248
283
  source?: RecordSource;
249
284
  status?: ContactStatus;
package/dist/types.d.mts CHANGED
@@ -84,12 +84,21 @@ export interface Company {
84
84
  }
85
85
  export interface Deal {
86
86
  closeDate: Date | null;
87
+ /** The organisation this deal belongs to, when one is linked. */
88
+ companyId: string | null;
87
89
  contactId: string | null;
88
90
  createdAt: Date;
89
91
  id: string;
90
92
  name: string;
91
93
  orgId: string;
92
94
  owner: string | null;
95
+ /**
96
+ * The board this deal belongs to. NULL means the org's default pipeline: the
97
+ * sync mirror wrote deals before pipelines existed, and a deal created with
98
+ * no pipeline lands on the default. Stage keys are unique per pipeline, so
99
+ * a board must filter on this, never on stage key alone.
100
+ */
101
+ pipelineId: string | null;
93
102
  position: number | null;
94
103
  stage: DealStage;
95
104
  /**
@@ -116,6 +125,11 @@ export interface Task {
116
125
  createdAt: Date;
117
126
  createdBy: string | null;
118
127
  description: string | null;
128
+ /**
129
+ * Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
130
+ * moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
131
+ */
132
+ dueAllDay?: boolean | null;
119
133
  dueAt: Date | null;
120
134
  entityId: string | null;
121
135
  entityType: TaskEntityType | null;
@@ -203,6 +217,11 @@ export interface CreateTaskInput {
203
217
  assignee?: string;
204
218
  createdBy?: string;
205
219
  description?: string;
220
+ /**
221
+ * Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
222
+ * moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
223
+ */
224
+ dueAllDay?: boolean | null;
206
225
  dueAt?: Date;
207
226
  entityId?: string;
208
227
  entityType?: TaskEntityType;
@@ -212,6 +231,11 @@ export interface CreateTaskInput {
212
231
  export interface UpdateTaskInput {
213
232
  assignee?: string | null;
214
233
  description?: string | null;
234
+ /**
235
+ * Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
236
+ * moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
237
+ */
238
+ dueAllDay?: boolean | null;
215
239
  dueAt?: Date | null;
216
240
  entityId?: string | null;
217
241
  entityType?: TaskEntityType | null;
@@ -244,6 +268,17 @@ export interface ContactFilters {
244
268
  * row is no longer sync-provenanced.
245
269
  */
246
270
  excludeArchived?: boolean;
271
+ /**
272
+ * Case-insensitive substring match on name or email.
273
+ *
274
+ * Applied in SQL, not over an already-fetched page: a filter applied after
275
+ * paging returns a partial page and a cursor that skips the rows it dropped,
276
+ * so "page 2" of a search is not the second page of the matches. An empty or
277
+ * whitespace-only value is ignored rather than matching nothing.
278
+ *
279
+ * LIKE metacharacters in the term are escaped and matched literally — a
280
+ * search for `%` finds contacts containing a percent sign, not every row.
281
+ */
247
282
  search?: string;
248
283
  source?: RecordSource;
249
284
  status?: ContactStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clivly/core",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "description": "ORM/auth-agnostic CRM adapter contracts and shared domain types for Clivly.",
6
6
  "license": "MIT",