@synap-core/workspace-templates 0.6.3 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Contract tests for the Business Developer CRM overlay.
3
+ *
4
+ * The catalog intentionally reads `templates.ts`, the generated source
5
+ * consumed by every template installer.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { toWorkspaceDefinition } from "./define.js";
10
+ import { getWorkspaceTemplate } from "./templates.js";
11
+
12
+ const template = (() => {
13
+ const resolved = getWorkspaceTemplate("business-developer");
14
+ if (!resolved) throw new Error("Business Developer template is not registered");
15
+ return resolved;
16
+ })();
17
+
18
+ const crmTemplate = (() => {
19
+ const resolved = getWorkspaceTemplate("crm");
20
+ if (!resolved) throw new Error("CRM template is not registered");
21
+ return resolved;
22
+ })();
23
+
24
+ function profile(slug: string) {
25
+ const found = template?.profiles.find((candidate) => candidate.slug === slug);
26
+ expect(found, `expected ${slug} profile`).toBeDefined();
27
+ if (!found) throw new Error(`expected ${slug} profile`);
28
+ return found;
29
+ }
30
+
31
+ function propertySlugs(slug: string) {
32
+ return new Set(profile(slug).properties.map((property) => property.slug));
33
+ }
34
+
35
+ describe("Business Developer workspace overlay", () => {
36
+ it("composes onto CRM without replacing its workspace identity", () => {
37
+ expect(template).toBeDefined();
38
+ expect(template.dependencies).toEqual([
39
+ {
40
+ slug: "crm",
41
+ kind: "workspace",
42
+ relation: "compose",
43
+ reason: "Adds a lead-first business-development workflow to your CRM workspace.",
44
+ },
45
+ ]);
46
+ expect(template.workspace.subtype).toBe("crm");
47
+ expect(template.workspace.visibility).toBeUndefined();
48
+ });
49
+
50
+ it("keeps CRM lifecycle facets workspace-local", () => {
51
+ for (const slug of ["crm-lead", "crm-client"]) {
52
+ const facet = profile(slug);
53
+ expect(facet.scope).toBe("WORKSPACE");
54
+ expect(facet.profileKind).toBe("role");
55
+ expect(facet.applicableKinds).toEqual(["person", "company"]);
56
+ }
57
+
58
+ expect(template.profiles.some((candidate) => ["lead", "client"].includes(candidate.slug))).toBe(false);
59
+
60
+ expect([...propertySlugs("crm-lead")].sort()).toEqual([
61
+ "assignedUserId",
62
+ "convertedAt",
63
+ "disqualificationReason",
64
+ "icpScore",
65
+ "lastContactedAt",
66
+ "lifecycleStage",
67
+ "nextAction",
68
+ "nextActionAt",
69
+ "outreachChannels",
70
+ "qualificationNotes",
71
+ "source",
72
+ ].sort());
73
+ });
74
+
75
+ it("models proposals and auditable commission separately from leads", () => {
76
+ const commercialStages = profile("deal")
77
+ .properties.find((property) => property.slug === "commercialStage")?.enumValues;
78
+ expect(commercialStages).toEqual(["draft", "proposed", "negotiating", "won", "lost"]);
79
+
80
+ expect([...propertySlugs("crm-commission-entitlement")].sort()).toEqual([
81
+ "earnedAt",
82
+ "estimatedAmount",
83
+ "feeBasisAmount",
84
+ "paidAt",
85
+ "rateBps",
86
+ "sequence",
87
+ "sourceDealId",
88
+ "sourcedByUserId",
89
+ "status",
90
+ "voidReason",
91
+ ].sort());
92
+
93
+ expect([...propertySlugs("deal")].sort()).toEqual([
94
+ "commercialStage",
95
+ "commissionEntitlementId",
96
+ "commissionStatus",
97
+ "expectedClientGain",
98
+ "lostReason",
99
+ "sourcedByUserId",
100
+ "synapFeeAmount",
101
+ "synapFeeModel",
102
+ "synapFixedFee",
103
+ "synapSuccessFeeRate",
104
+ "wonAt",
105
+ ].sort());
106
+
107
+ const entitlementId = profile("deal").properties.find(
108
+ (property) => property.slug === "commissionEntitlementId",
109
+ );
110
+ expect(entitlementId).toMatchObject({
111
+ valueType: "entity_id",
112
+ inputType: "entity_select",
113
+ targetProfileSlug: "crm-commission-entitlement",
114
+ });
115
+ expect(
116
+ profile("crm-commission-entitlement").properties.find(
117
+ (property) => property.slug === "sourceDealId",
118
+ ),
119
+ ).toMatchObject({
120
+ valueType: "entity_id",
121
+ inputType: "entity_select",
122
+ targetProfileSlug: "deal",
123
+ });
124
+
125
+ expect(
126
+ template.entityLinks?.map(({ sourceProfileSlug, targetProfileSlug, type }) => ({
127
+ sourceProfileSlug,
128
+ targetProfileSlug,
129
+ type,
130
+ })),
131
+ ).toEqual([
132
+ { sourceProfileSlug: "deal", targetProfileSlug: "crm-commission-entitlement", type: "crm_commission_for_deal" },
133
+ ]);
134
+ });
135
+
136
+ it("relies on CRM's single generic counterparty relationship", () => {
137
+ expect(
138
+ crmTemplate.entityLinks?.map(({ sourceProfileSlug, targetProfileSlug, type }) => ({
139
+ sourceProfileSlug,
140
+ targetProfileSlug,
141
+ type,
142
+ })),
143
+ ).toEqual([
144
+ { sourceProfileSlug: "person", targetProfileSlug: "deal", type: "linked_to_deal" },
145
+ { sourceProfileSlug: "company", targetProfileSlug: "deal", type: "linked_to_deal" },
146
+ ]);
147
+ });
148
+
149
+ it("keeps playbooks lead-first and proposal-gated", () => {
150
+ expect(
151
+ template.playbooks?.map(({ name, subjectProfile }) => ({ name, profileSlug: subjectProfile?.profileSlug })),
152
+ ).toEqual([
153
+ { name: "Qualify a CRM lead", profileSlug: "crm-lead" },
154
+ { name: "Advance a commercial proposal", profileSlug: "deal" },
155
+ ]);
156
+ expect(template.playbooks?.[0]?.goalTemplate ?? "").toMatch(/Do not create a deal/);
157
+ expect(template.playbooks?.[1]?.goalTemplate ?? "").toMatch(/Never mark the counterparty as a CRM client/);
158
+ });
159
+
160
+ it("declares the complete, human-confirmed handoff as a reusable flow", () => {
161
+ const handoff = template.automations?.find(
162
+ (automation) => automation.name === "Win and hand off",
163
+ );
164
+ expect(handoff).toMatchObject({
165
+ key: "crm-win-and-handoff",
166
+ triggerType: "manual",
167
+ status: "active",
168
+ });
169
+ expect(handoff?.flow.nodes).toEqual(
170
+ expect.arrayContaining([
171
+ expect.objectContaining({ id: "read-deal", type: "entity_read" }),
172
+ expect.objectContaining({ id: "find-party", type: "related_entities" }),
173
+ expect.objectContaining({ id: "guard-lead", type: "guard" }),
174
+ expect.objectContaining({ id: "commission-sequence", type: "select" }),
175
+ expect.objectContaining({ id: "claim-first-client-deal", type: "claim" }),
176
+ expect.objectContaining({ id: "claim-commission-entitlement", type: "claim" }),
177
+ expect.objectContaining({ id: "prior-won-deals", type: "related_entities" }),
178
+ expect.objectContaining({
179
+ id: "is-first-client-deal",
180
+ type: "compute",
181
+ data: expect.objectContaining({
182
+ left: "{{steps.claim-first-client-deal.output.claimed}}",
183
+ right: "{{steps.has-no-prior-won-deals.output.result}}",
184
+ }),
185
+ }),
186
+ expect.objectContaining({ id: "create-commission", type: "output" }),
187
+ expect.objectContaining({ id: "convert-lead", type: "output" }),
188
+ ]),
189
+ );
190
+ expect(handoff?.flow.nodes).toContainEqual(
191
+ expect.objectContaining({
192
+ id: "create-commission",
193
+ data: expect.objectContaining({
194
+ outputType: "entity_create",
195
+ config: expect.objectContaining({ dedupeBy: "sourceDealId" }),
196
+ }),
197
+ }),
198
+ );
199
+ expect(handoff?.flow.nodes).toContainEqual(
200
+ expect.objectContaining({
201
+ id: "guard-deal",
202
+ data: expect.objectContaining({
203
+ checks: expect.arrayContaining([
204
+ expect.objectContaining({
205
+ path: "steps.read-deal.output.entity.properties.commercialStage",
206
+ anyOf: expect.arrayContaining([
207
+ expect.objectContaining({ path: "steps.read-deal.output.entity.properties.commercialStage", equals: "won" }),
208
+ expect.objectContaining({ path: "steps.read-deal.output.entity.properties.dealStage", equals: "won" }),
209
+ ]),
210
+ }),
211
+ ]),
212
+ }),
213
+ }),
214
+ );
215
+
216
+ expect(template.actionPlacements).toEqual([
217
+ expect.objectContaining({
218
+ profileSlug: "person",
219
+ kind: "playbook",
220
+ ref: "Qualify a CRM lead",
221
+ when: { requiredFacetSlugs: ["crm-lead"] },
222
+ }),
223
+ expect.objectContaining({
224
+ profileSlug: "company",
225
+ kind: "playbook",
226
+ ref: "Qualify a CRM lead",
227
+ when: { requiredFacetSlugs: ["crm-lead"] },
228
+ }),
229
+ expect.objectContaining({
230
+ profileSlug: "deal",
231
+ kind: "playbook",
232
+ ref: "Advance a commercial proposal",
233
+ }),
234
+ expect.objectContaining({
235
+ profileSlug: "deal",
236
+ kind: "automation",
237
+ ref: "Win and hand off",
238
+ label: "Hand off won proposal",
239
+ when: {
240
+ propertyAnyEquals: { commercialStage: ["won"], dealStage: ["won"] },
241
+ propertyNotEquals: { handoffStatus: "handed_off" },
242
+ },
243
+ confirmation: expect.objectContaining({
244
+ confirmLabel: "Hand off proposal",
245
+ }),
246
+ }),
247
+ ]);
248
+ });
249
+
250
+ it("carries playbooks through the workspace-create converter", () => {
251
+ const { definition } = toWorkspaceDefinition("business-developer");
252
+ const playbooks = definition.playbooks ?? [];
253
+
254
+ expect(playbooks).toHaveLength(2);
255
+ expect(playbooks[0]).toMatchObject({
256
+ name: "Qualify a CRM lead",
257
+ subjectProfile: { profileSlug: "crm-lead" },
258
+ params: [{ name: "lead", type: "string", description: "CRM lead" }],
259
+ });
260
+ expect(playbooks[1]).toMatchObject({
261
+ name: "Advance a commercial proposal",
262
+ subjectProfile: { profileSlug: "deal" },
263
+ params: [{ name: "deal", type: "string", description: "Commercial proposal" }],
264
+ });
265
+
266
+ expect(definition.flowAutomations).toHaveLength(1);
267
+ expect(definition.flowAutomations?.[0]).toMatchObject({
268
+ key: "crm-win-and-handoff",
269
+ name: "Win and hand off",
270
+ triggerType: "manual",
271
+ flowDefinition: expect.objectContaining({
272
+ nodes: expect.arrayContaining([
273
+ expect.objectContaining({ id: "read-deal", type: "entity_read" }),
274
+ expect.objectContaining({ id: "commission-sequence", type: "select" }),
275
+ expect.objectContaining({ id: "claim-first-client-deal", type: "claim" }),
276
+ ]),
277
+ }),
278
+ });
279
+ });
280
+ });