@thebookingkit/server 0.1.1

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.
Files changed (126) hide show
  1. package/.turbo/turbo-build.log +6 -0
  2. package/.turbo/turbo-test.log +20 -0
  3. package/CHANGELOG.md +9 -0
  4. package/dist/__tests__/api.test.d.ts +2 -0
  5. package/dist/__tests__/api.test.d.ts.map +1 -0
  6. package/dist/__tests__/api.test.js +280 -0
  7. package/dist/__tests__/api.test.js.map +1 -0
  8. package/dist/__tests__/auth.test.d.ts +2 -0
  9. package/dist/__tests__/auth.test.d.ts.map +1 -0
  10. package/dist/__tests__/auth.test.js +78 -0
  11. package/dist/__tests__/auth.test.js.map +1 -0
  12. package/dist/__tests__/concurrent-booking.test.d.ts +2 -0
  13. package/dist/__tests__/concurrent-booking.test.d.ts.map +1 -0
  14. package/dist/__tests__/concurrent-booking.test.js +111 -0
  15. package/dist/__tests__/concurrent-booking.test.js.map +1 -0
  16. package/dist/__tests__/multi-tenancy.test.d.ts +2 -0
  17. package/dist/__tests__/multi-tenancy.test.d.ts.map +1 -0
  18. package/dist/__tests__/multi-tenancy.test.js +196 -0
  19. package/dist/__tests__/multi-tenancy.test.js.map +1 -0
  20. package/dist/__tests__/serialization-retry.test.d.ts +2 -0
  21. package/dist/__tests__/serialization-retry.test.d.ts.map +1 -0
  22. package/dist/__tests__/serialization-retry.test.js +53 -0
  23. package/dist/__tests__/serialization-retry.test.js.map +1 -0
  24. package/dist/__tests__/webhooks.test.d.ts +2 -0
  25. package/dist/__tests__/webhooks.test.d.ts.map +1 -0
  26. package/dist/__tests__/webhooks.test.js +286 -0
  27. package/dist/__tests__/webhooks.test.js.map +1 -0
  28. package/dist/__tests__/workflows.test.d.ts +2 -0
  29. package/dist/__tests__/workflows.test.d.ts.map +1 -0
  30. package/dist/__tests__/workflows.test.js +299 -0
  31. package/dist/__tests__/workflows.test.js.map +1 -0
  32. package/dist/adapters/calendar-adapter.d.ts +47 -0
  33. package/dist/adapters/calendar-adapter.d.ts.map +1 -0
  34. package/dist/adapters/calendar-adapter.js +2 -0
  35. package/dist/adapters/calendar-adapter.js.map +1 -0
  36. package/dist/adapters/email-adapter.d.ts +65 -0
  37. package/dist/adapters/email-adapter.d.ts.map +1 -0
  38. package/dist/adapters/email-adapter.js +40 -0
  39. package/dist/adapters/email-adapter.js.map +1 -0
  40. package/dist/adapters/index.d.ts +9 -0
  41. package/dist/adapters/index.d.ts.map +1 -0
  42. package/dist/adapters/index.js +3 -0
  43. package/dist/adapters/index.js.map +1 -0
  44. package/dist/adapters/job-adapter.d.ts +26 -0
  45. package/dist/adapters/job-adapter.d.ts.map +1 -0
  46. package/dist/adapters/job-adapter.js +13 -0
  47. package/dist/adapters/job-adapter.js.map +1 -0
  48. package/dist/adapters/payment-adapter.d.ts +106 -0
  49. package/dist/adapters/payment-adapter.d.ts.map +1 -0
  50. package/dist/adapters/payment-adapter.js +8 -0
  51. package/dist/adapters/payment-adapter.js.map +1 -0
  52. package/dist/adapters/sms-adapter.d.ts +33 -0
  53. package/dist/adapters/sms-adapter.d.ts.map +1 -0
  54. package/dist/adapters/sms-adapter.js +8 -0
  55. package/dist/adapters/sms-adapter.js.map +1 -0
  56. package/dist/adapters/storage-adapter.d.ts +12 -0
  57. package/dist/adapters/storage-adapter.d.ts.map +1 -0
  58. package/dist/adapters/storage-adapter.js +2 -0
  59. package/dist/adapters/storage-adapter.js.map +1 -0
  60. package/dist/api.d.ts +223 -0
  61. package/dist/api.d.ts.map +1 -0
  62. package/dist/api.js +271 -0
  63. package/dist/api.js.map +1 -0
  64. package/dist/auth.d.ts +71 -0
  65. package/dist/auth.d.ts.map +1 -0
  66. package/dist/auth.js +81 -0
  67. package/dist/auth.js.map +1 -0
  68. package/dist/booking-tokens.d.ts +23 -0
  69. package/dist/booking-tokens.d.ts.map +1 -0
  70. package/dist/booking-tokens.js +52 -0
  71. package/dist/booking-tokens.js.map +1 -0
  72. package/dist/email-templates.d.ts +36 -0
  73. package/dist/email-templates.d.ts.map +1 -0
  74. package/dist/email-templates.js +112 -0
  75. package/dist/email-templates.js.map +1 -0
  76. package/dist/index.d.ts +13 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +22 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/multi-tenancy.d.ts +132 -0
  81. package/dist/multi-tenancy.d.ts.map +1 -0
  82. package/dist/multi-tenancy.js +188 -0
  83. package/dist/multi-tenancy.js.map +1 -0
  84. package/dist/notification-jobs.d.ts +143 -0
  85. package/dist/notification-jobs.d.ts.map +1 -0
  86. package/dist/notification-jobs.js +278 -0
  87. package/dist/notification-jobs.js.map +1 -0
  88. package/dist/serialization-retry.d.ts +28 -0
  89. package/dist/serialization-retry.d.ts.map +1 -0
  90. package/dist/serialization-retry.js +71 -0
  91. package/dist/serialization-retry.js.map +1 -0
  92. package/dist/webhooks.d.ts +164 -0
  93. package/dist/webhooks.d.ts.map +1 -0
  94. package/dist/webhooks.js +228 -0
  95. package/dist/webhooks.js.map +1 -0
  96. package/dist/workflows.d.ts +169 -0
  97. package/dist/workflows.d.ts.map +1 -0
  98. package/dist/workflows.js +251 -0
  99. package/dist/workflows.js.map +1 -0
  100. package/package.json +32 -0
  101. package/src/__tests__/api.test.ts +354 -0
  102. package/src/__tests__/auth.test.ts +111 -0
  103. package/src/__tests__/concurrent-booking.test.ts +170 -0
  104. package/src/__tests__/multi-tenancy.test.ts +267 -0
  105. package/src/__tests__/serialization-retry.test.ts +76 -0
  106. package/src/__tests__/webhooks.test.ts +412 -0
  107. package/src/__tests__/workflows.test.ts +422 -0
  108. package/src/adapters/calendar-adapter.ts +49 -0
  109. package/src/adapters/email-adapter.ts +108 -0
  110. package/src/adapters/index.ts +36 -0
  111. package/src/adapters/job-adapter.ts +26 -0
  112. package/src/adapters/payment-adapter.ts +118 -0
  113. package/src/adapters/sms-adapter.ts +35 -0
  114. package/src/adapters/storage-adapter.ts +11 -0
  115. package/src/api.ts +446 -0
  116. package/src/auth.ts +146 -0
  117. package/src/booking-tokens.ts +61 -0
  118. package/src/email-templates.ts +140 -0
  119. package/src/index.ts +192 -0
  120. package/src/multi-tenancy.ts +301 -0
  121. package/src/notification-jobs.ts +428 -0
  122. package/src/serialization-retry.ts +94 -0
  123. package/src/webhooks.ts +378 -0
  124. package/src/workflows.ts +441 -0
  125. package/tsconfig.json +9 -0
  126. package/vitest.config.ts +7 -0
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ resolveEffectiveSettings,
4
+ getRolePermissions,
5
+ roleHasPermission,
6
+ assertOrgPermission,
7
+ assertTenantScope,
8
+ buildOrgBookingUrl,
9
+ parseOrgBookingPath,
10
+ TenantAuthorizationError,
11
+ GLOBAL_DEFAULTS,
12
+ type OrgMember,
13
+ type OrgSettings,
14
+ type ProviderSettings,
15
+ type EventTypeSettings,
16
+ } from "../multi-tenancy.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // resolveEffectiveSettings
20
+ // ---------------------------------------------------------------------------
21
+
22
+ describe("resolveEffectiveSettings", () => {
23
+ it("uses global defaults when no settings provided", () => {
24
+ const resolved = resolveEffectiveSettings();
25
+ expect(resolved.timezone).toBe(GLOBAL_DEFAULTS.timezone);
26
+ expect(resolved.currency).toBe(GLOBAL_DEFAULTS.currency);
27
+ expect(resolved.bufferMinutes).toBe(GLOBAL_DEFAULTS.bufferMinutes);
28
+ expect(resolved.branding).toEqual({});
29
+ expect(resolved.bookingLimits).toEqual({});
30
+ });
31
+
32
+ it("applies org settings over global defaults", () => {
33
+ const org: OrgSettings = {
34
+ defaultTimezone: "America/New_York",
35
+ defaultCurrency: "EUR",
36
+ branding: { primaryColor: "#6366f1" },
37
+ };
38
+
39
+ const resolved = resolveEffectiveSettings(org);
40
+ expect(resolved.timezone).toBe("America/New_York");
41
+ expect(resolved.currency).toBe("EUR");
42
+ expect(resolved.branding.primaryColor).toBe("#6366f1");
43
+ });
44
+
45
+ it("provider settings override org settings", () => {
46
+ const org: OrgSettings = {
47
+ defaultTimezone: "America/New_York",
48
+ defaultCurrency: "USD",
49
+ };
50
+ const provider: ProviderSettings = {
51
+ timezone: "Europe/London",
52
+ currency: "GBP",
53
+ };
54
+
55
+ const resolved = resolveEffectiveSettings(org, provider);
56
+ expect(resolved.timezone).toBe("Europe/London");
57
+ expect(resolved.currency).toBe("GBP");
58
+ });
59
+
60
+ it("event type settings override provider settings", () => {
61
+ const org: OrgSettings = { defaultTimezone: "UTC" };
62
+ const provider: ProviderSettings = { timezone: "America/New_York" };
63
+ const eventType: EventTypeSettings = { timezone: "Asia/Tokyo" };
64
+
65
+ const resolved = resolveEffectiveSettings(org, provider, eventType);
66
+ expect(resolved.timezone).toBe("Asia/Tokyo");
67
+ });
68
+
69
+ it("merges branding settings additively", () => {
70
+ const org: OrgSettings = {
71
+ branding: { primaryColor: "#000", logoUrl: "https://example.com/logo.png" },
72
+ };
73
+ const provider: ProviderSettings = {
74
+ branding: { primaryColor: "#fff" },
75
+ };
76
+
77
+ const resolved = resolveEffectiveSettings(org, provider);
78
+ expect(resolved.branding.primaryColor).toBe("#fff"); // provider overrides
79
+ expect(resolved.branding.logoUrl).toBe("https://example.com/logo.png"); // org preserved
80
+ });
81
+
82
+ it("merges booking limits additively", () => {
83
+ const org: OrgSettings = {
84
+ defaultBookingLimits: { maxPerDay: 5 },
85
+ };
86
+ const provider: ProviderSettings = {
87
+ bookingLimits: { maxPerWeek: 20 },
88
+ };
89
+
90
+ const resolved = resolveEffectiveSettings(org, provider);
91
+ expect(resolved.bookingLimits).toMatchObject({ maxPerDay: 5, maxPerWeek: 20 });
92
+ });
93
+
94
+ it("handles null settings gracefully", () => {
95
+ expect(() => resolveEffectiveSettings(null, null, null)).not.toThrow();
96
+ });
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // getRolePermissions
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe("getRolePermissions", () => {
104
+ it("owner has all permissions", () => {
105
+ const perms = getRolePermissions("owner");
106
+ expect(perms).toContain("manage:members");
107
+ expect(perms).toContain("manage:teams");
108
+ expect(perms).toContain("view:all-bookings");
109
+ expect(perms).toContain("manage:organization");
110
+ expect(perms).toContain("view:analytics");
111
+ });
112
+
113
+ it("admin has management permissions but not member management", () => {
114
+ const perms = getRolePermissions("admin");
115
+ expect(perms).toContain("manage:teams");
116
+ expect(perms).toContain("view:all-bookings");
117
+ expect(perms).not.toContain("manage:members");
118
+ expect(perms).not.toContain("manage:organization");
119
+ });
120
+
121
+ it("member has only own-resource permissions", () => {
122
+ const perms = getRolePermissions("member");
123
+ expect(perms).toContain("view:own-bookings");
124
+ expect(perms).toContain("manage:own-availability");
125
+ expect(perms).not.toContain("view:all-bookings");
126
+ expect(perms).not.toContain("manage:teams");
127
+ });
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // roleHasPermission
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe("roleHasPermission", () => {
135
+ it("returns true for permitted action", () => {
136
+ expect(roleHasPermission("owner", "manage:members")).toBe(true);
137
+ });
138
+
139
+ it("returns false for unpermitted action", () => {
140
+ expect(roleHasPermission("member", "manage:members")).toBe(false);
141
+ });
142
+
143
+ it("admin can view all bookings", () => {
144
+ expect(roleHasPermission("admin", "view:all-bookings")).toBe(true);
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // assertOrgPermission
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe("assertOrgPermission", () => {
153
+ const ownerMember: OrgMember = {
154
+ userId: "user-1",
155
+ organizationId: "org-1",
156
+ role: "owner",
157
+ };
158
+
159
+ const regularMember: OrgMember = {
160
+ userId: "user-2",
161
+ organizationId: "org-1",
162
+ role: "member",
163
+ };
164
+
165
+ it("does not throw for permitted action", () => {
166
+ expect(() =>
167
+ assertOrgPermission(ownerMember, "manage:members"),
168
+ ).not.toThrow();
169
+ });
170
+
171
+ it("throws for unpermitted action", () => {
172
+ expect(() =>
173
+ assertOrgPermission(regularMember, "manage:members"),
174
+ ).toThrow(TenantAuthorizationError);
175
+ });
176
+
177
+ it("throws with descriptive message", () => {
178
+ expect(() =>
179
+ assertOrgPermission(regularMember, "view:all-bookings"),
180
+ ).toThrow('"member"');
181
+ });
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // assertTenantScope
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("assertTenantScope", () => {
189
+ it("does not throw when org IDs match", () => {
190
+ expect(() => assertTenantScope("org-1", "org-1")).not.toThrow();
191
+ });
192
+
193
+ it("does not throw when resource has no org ID", () => {
194
+ expect(() => assertTenantScope(null, "org-1")).not.toThrow();
195
+ expect(() => assertTenantScope(undefined, "org-1")).not.toThrow();
196
+ });
197
+
198
+ it("throws when org IDs don't match", () => {
199
+ expect(() => assertTenantScope("org-2", "org-1")).toThrow(
200
+ TenantAuthorizationError,
201
+ );
202
+ expect(() => assertTenantScope("org-2", "org-1")).toThrow(
203
+ "does not belong to the current organization",
204
+ );
205
+ });
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // buildOrgBookingUrl
210
+ // ---------------------------------------------------------------------------
211
+
212
+ describe("buildOrgBookingUrl", () => {
213
+ it("builds the correct URL", () => {
214
+ const url = buildOrgBookingUrl(
215
+ "acme-corp",
216
+ "dr-smith",
217
+ "consultation",
218
+ "https://booking.example.com",
219
+ );
220
+ expect(url).toBe(
221
+ "https://booking.example.com/acme-corp/dr-smith/consultation",
222
+ );
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // parseOrgBookingPath
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe("parseOrgBookingPath", () => {
231
+ it("parses valid path", () => {
232
+ const result = parseOrgBookingPath("/acme-corp/dr-smith/consultation");
233
+ expect(result).toEqual({
234
+ orgSlug: "acme-corp",
235
+ providerSlug: "dr-smith",
236
+ eventTypeSlug: "consultation",
237
+ });
238
+ });
239
+
240
+ it("returns null for invalid path", () => {
241
+ expect(parseOrgBookingPath("/only-two/segments")).toBeNull();
242
+ expect(parseOrgBookingPath("/too/many/path/segments")).toBeNull();
243
+ expect(parseOrgBookingPath("no-leading-slash")).toBeNull();
244
+ });
245
+
246
+ it("returns null for empty path", () => {
247
+ expect(parseOrgBookingPath("")).toBeNull();
248
+ });
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // GLOBAL_DEFAULTS
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe("GLOBAL_DEFAULTS", () => {
256
+ it("uses UTC timezone", () => {
257
+ expect(GLOBAL_DEFAULTS.timezone).toBe("UTC");
258
+ });
259
+
260
+ it("uses USD currency", () => {
261
+ expect(GLOBAL_DEFAULTS.currency).toBe("USD");
262
+ });
263
+
264
+ it("uses 0 buffer minutes", () => {
265
+ expect(GLOBAL_DEFAULTS.bufferMinutes).toBe(0);
266
+ });
267
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ withSerializableRetry,
4
+ BookingConflictError,
5
+ SerializationRetryExhaustedError,
6
+ } from "../index.js";
7
+
8
+ function makePostgresError(code: string, message = "pg error") {
9
+ const err = new Error(message) as Error & { code: string };
10
+ err.code = code;
11
+ return err;
12
+ }
13
+
14
+ describe("withSerializableRetry", () => {
15
+ it("returns the result on success", async () => {
16
+ const result = await withSerializableRetry(async () => ({ id: "123" }));
17
+ expect(result).toEqual({ id: "123" });
18
+ });
19
+
20
+ it("throws BookingConflictError on exclusion violation (23P01)", async () => {
21
+ const fn = vi.fn().mockRejectedValue(makePostgresError("23P01"));
22
+
23
+ await expect(withSerializableRetry(fn)).rejects.toThrow(
24
+ BookingConflictError,
25
+ );
26
+ // Should NOT retry on exclusion violation
27
+ expect(fn).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it("retries on serialization failure (40001) and succeeds", async () => {
31
+ const fn = vi
32
+ .fn()
33
+ .mockRejectedValueOnce(makePostgresError("40001"))
34
+ .mockRejectedValueOnce(makePostgresError("40001"))
35
+ .mockResolvedValueOnce({ id: "456" });
36
+
37
+ const result = await withSerializableRetry(fn, {
38
+ maxRetries: 3,
39
+ baseDelayMs: 1, // fast for tests
40
+ });
41
+
42
+ expect(result).toEqual({ id: "456" });
43
+ expect(fn).toHaveBeenCalledTimes(3);
44
+ });
45
+
46
+ it("throws SerializationRetryExhaustedError when all retries fail", async () => {
47
+ const fn = vi.fn().mockRejectedValue(makePostgresError("40001"));
48
+
49
+ await expect(
50
+ withSerializableRetry(fn, { maxRetries: 2, baseDelayMs: 1 }),
51
+ ).rejects.toThrow(SerializationRetryExhaustedError);
52
+
53
+ // Initial attempt + 2 retries = 3 calls
54
+ expect(fn).toHaveBeenCalledTimes(3);
55
+ });
56
+
57
+ it("rethrows unknown errors without retrying", async () => {
58
+ const fn = vi.fn().mockRejectedValue(new Error("connection refused"));
59
+
60
+ await expect(withSerializableRetry(fn)).rejects.toThrow(
61
+ "connection refused",
62
+ );
63
+ expect(fn).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it("uses default options (3 retries, 50ms base delay)", async () => {
67
+ const fn = vi
68
+ .fn()
69
+ .mockRejectedValueOnce(makePostgresError("40001"))
70
+ .mockResolvedValueOnce("ok");
71
+
72
+ const result = await withSerializableRetry(fn);
73
+ expect(result).toBe("ok");
74
+ expect(fn).toHaveBeenCalledTimes(2);
75
+ });
76
+ });