@tailor-platform/erp-kit 0.0.1 → 0.1.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.
Files changed (213) hide show
  1. package/README.md +196 -28
  2. package/dist/cli.js +894 -0
  3. package/package.json +65 -8
  4. package/rules/app-compose/backend/auth.md +78 -0
  5. package/rules/app-compose/frontend/auth.md +55 -0
  6. package/rules/app-compose/frontend/component.md +55 -0
  7. package/rules/app-compose/frontend/page.md +86 -0
  8. package/rules/app-compose/frontend/screen-detailview.md +112 -0
  9. package/rules/app-compose/frontend/screen-form.md +145 -0
  10. package/rules/app-compose/frontend/screen-listview.md +159 -0
  11. package/rules/app-compose/structure.md +32 -0
  12. package/rules/module-development/commands.md +54 -0
  13. package/rules/module-development/cross-module-type-injection.md +28 -0
  14. package/rules/module-development/dependency-modules.md +24 -0
  15. package/rules/module-development/errors.md +12 -0
  16. package/rules/module-development/executors.md +67 -0
  17. package/rules/module-development/exports.md +13 -0
  18. package/rules/module-development/models.md +34 -0
  19. package/rules/module-development/structure.md +27 -0
  20. package/rules/module-development/sync-vs-async-operations.md +83 -0
  21. package/rules/module-development/testing.md +43 -0
  22. package/rules/sdk-best-practices/db-relations.md +74 -0
  23. package/rules/sdk-best-practices/sdk-docs.md +14 -0
  24. package/schemas/app-compose/actors.yml +34 -0
  25. package/schemas/app-compose/business-flow.yml +50 -0
  26. package/schemas/app-compose/requirements.yml +33 -0
  27. package/schemas/app-compose/resolver.yml +47 -0
  28. package/schemas/app-compose/screen.yml +81 -0
  29. package/schemas/app-compose/story.yml +67 -0
  30. package/schemas/module/command.yml +52 -0
  31. package/schemas/module/feature.yml +58 -0
  32. package/schemas/module/model.yml +70 -0
  33. package/schemas/module/module.yml +50 -0
  34. package/skills/1-module-docs/SKILL.md +107 -0
  35. package/skills/2-module-feature-breakdown/SKILL.md +66 -0
  36. package/skills/3-module-doc-review/SKILL.md +230 -0
  37. package/skills/4-module-tdd-implementation/SKILL.md +56 -0
  38. package/skills/5-module-implementation-review/SKILL.md +400 -0
  39. package/skills/app-compose-1-requirement-analysis/SKILL.md +85 -0
  40. package/skills/app-compose-2-requirements-breakdown/SKILL.md +88 -0
  41. package/skills/app-compose-3-doc-review/SKILL.md +112 -0
  42. package/skills/app-compose-4-design-mock/SKILL.md +248 -0
  43. package/skills/app-compose-5-design-mock-review/SKILL.md +283 -0
  44. package/skills/app-compose-6-implementation-spec/SKILL.md +122 -0
  45. package/skills/mock-scenario/SKILL.md +118 -0
  46. package/src/app.ts +1 -0
  47. package/src/cli.ts +120 -0
  48. package/src/commands/check.test.ts +30 -0
  49. package/src/commands/check.ts +66 -0
  50. package/src/commands/init.test.ts +77 -0
  51. package/src/commands/init.ts +87 -0
  52. package/src/commands/mock/index.ts +53 -0
  53. package/src/commands/mock/start.ts +179 -0
  54. package/src/commands/mock/validate.test.ts +185 -0
  55. package/src/commands/mock/validate.ts +198 -0
  56. package/src/commands/scaffold.test.ts +76 -0
  57. package/src/commands/scaffold.ts +119 -0
  58. package/src/commands/sync-check.test.ts +125 -0
  59. package/src/commands/sync-check.ts +182 -0
  60. package/src/integration.test.ts +63 -0
  61. package/src/mdschema.ts +48 -0
  62. package/src/mockServer.ts +55 -0
  63. package/src/module.ts +86 -0
  64. package/src/modules/accounting/.gitkeep +0 -0
  65. package/src/modules/coa-management/.gitkeep +0 -0
  66. package/src/modules/inventory/.gitkeep +0 -0
  67. package/src/modules/manufacturing/.gitkeep +0 -0
  68. package/src/modules/primitives/README.md +39 -0
  69. package/src/modules/primitives/command/activateCategory.test.ts +75 -0
  70. package/src/modules/primitives/command/activateCategory.ts +50 -0
  71. package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
  72. package/src/modules/primitives/command/activateCurrency.ts +50 -0
  73. package/src/modules/primitives/command/activateUnit.test.ts +53 -0
  74. package/src/modules/primitives/command/activateUnit.ts +50 -0
  75. package/src/modules/primitives/command/convertAmount.test.ts +275 -0
  76. package/src/modules/primitives/command/convertAmount.ts +126 -0
  77. package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
  78. package/src/modules/primitives/command/convertQuantity.ts +73 -0
  79. package/src/modules/primitives/command/createCategory.test.ts +126 -0
  80. package/src/modules/primitives/command/createCategory.ts +89 -0
  81. package/src/modules/primitives/command/createCurrency.test.ts +191 -0
  82. package/src/modules/primitives/command/createCurrency.ts +77 -0
  83. package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
  84. package/src/modules/primitives/command/createExchangeRate.ts +91 -0
  85. package/src/modules/primitives/command/createUnit.test.ts +214 -0
  86. package/src/modules/primitives/command/createUnit.ts +88 -0
  87. package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
  88. package/src/modules/primitives/command/deactivateCategory.ts +62 -0
  89. package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
  90. package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
  91. package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
  92. package/src/modules/primitives/command/deactivateUnit.ts +62 -0
  93. package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
  94. package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
  95. package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
  96. package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
  97. package/src/modules/primitives/db/currency.ts +30 -0
  98. package/src/modules/primitives/db/exchangeRate.ts +28 -0
  99. package/src/modules/primitives/db/unit.ts +32 -0
  100. package/src/modules/primitives/db/uomCategory.ts +32 -0
  101. package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
  102. package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
  103. package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
  104. package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
  105. package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
  106. package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
  107. package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
  108. package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
  109. package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
  110. package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
  111. package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
  112. package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
  113. package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
  114. package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
  115. package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
  116. package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
  117. package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
  118. package/src/modules/primitives/docs/features/uom-categories.md +52 -0
  119. package/src/modules/primitives/docs/models/Currency.md +45 -0
  120. package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
  121. package/src/modules/primitives/docs/models/Unit.md +46 -0
  122. package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
  123. package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
  124. package/src/modules/primitives/index.ts +40 -0
  125. package/src/modules/primitives/lib/errors.ts +138 -0
  126. package/src/modules/primitives/lib/types.ts +20 -0
  127. package/src/modules/primitives/module.ts +66 -0
  128. package/src/modules/primitives/permissions.ts +18 -0
  129. package/src/modules/primitives/tailor.config.ts +11 -0
  130. package/src/modules/primitives/testing/fixtures.ts +161 -0
  131. package/src/modules/product-management/.gitkeep +0 -0
  132. package/src/modules/purchase/.gitkeep +0 -0
  133. package/src/modules/sales/.gitkeep +0 -0
  134. package/src/modules/shared/createContext.test.ts +39 -0
  135. package/src/modules/shared/createContext.ts +15 -0
  136. package/src/modules/shared/defineCommand.test.ts +42 -0
  137. package/src/modules/shared/defineCommand.ts +19 -0
  138. package/src/modules/shared/definePermissions.test.ts +146 -0
  139. package/src/modules/shared/definePermissions.ts +94 -0
  140. package/src/modules/shared/entityTypes.ts +15 -0
  141. package/src/modules/shared/errors.ts +22 -0
  142. package/src/modules/shared/index.ts +1 -0
  143. package/src/modules/shared/internal.ts +13 -0
  144. package/src/modules/shared/requirePermission.test.ts +47 -0
  145. package/src/modules/shared/requirePermission.ts +8 -0
  146. package/src/modules/shared/types.ts +4 -0
  147. package/src/modules/supplier-management/.gitkeep +0 -0
  148. package/src/modules/supplier-portal/.gitkeep +0 -0
  149. package/src/modules/testing/index.ts +120 -0
  150. package/src/modules/user-management/README.md +38 -0
  151. package/src/modules/user-management/command/activateUser.test.ts +112 -0
  152. package/src/modules/user-management/command/activateUser.ts +67 -0
  153. package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
  154. package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
  155. package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
  156. package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
  157. package/src/modules/user-management/command/createPermission.test.ts +143 -0
  158. package/src/modules/user-management/command/createPermission.ts +66 -0
  159. package/src/modules/user-management/command/createRole.test.ts +115 -0
  160. package/src/modules/user-management/command/createRole.ts +52 -0
  161. package/src/modules/user-management/command/createUser.test.ts +198 -0
  162. package/src/modules/user-management/command/createUser.ts +85 -0
  163. package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
  164. package/src/modules/user-management/command/deactivateUser.ts +67 -0
  165. package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
  166. package/src/modules/user-management/command/logAuditEvent.ts +59 -0
  167. package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
  168. package/src/modules/user-management/command/reactivateUser.ts +67 -0
  169. package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
  170. package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
  171. package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
  172. package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
  173. package/src/modules/user-management/db/auditEvent.ts +47 -0
  174. package/src/modules/user-management/db/permission.ts +31 -0
  175. package/src/modules/user-management/db/role.ts +28 -0
  176. package/src/modules/user-management/db/rolePermission.ts +44 -0
  177. package/src/modules/user-management/db/user.ts +38 -0
  178. package/src/modules/user-management/db/userRole.ts +44 -0
  179. package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
  180. package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
  181. package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
  182. package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
  183. package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
  184. package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
  185. package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
  186. package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
  187. package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
  188. package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
  189. package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
  190. package/src/modules/user-management/docs/features/audit-trail.md +80 -0
  191. package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
  192. package/src/modules/user-management/docs/features/user-account-management.md +64 -0
  193. package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
  194. package/src/modules/user-management/docs/models/Permission.md +31 -0
  195. package/src/modules/user-management/docs/models/Role.md +31 -0
  196. package/src/modules/user-management/docs/models/RolePermission.md +33 -0
  197. package/src/modules/user-management/docs/models/User.md +47 -0
  198. package/src/modules/user-management/docs/models/UserRole.md +34 -0
  199. package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
  200. package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
  201. package/src/modules/user-management/generated/enums.ts +24 -0
  202. package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
  203. package/src/modules/user-management/index.ts +32 -0
  204. package/src/modules/user-management/lib/errors.ts +81 -0
  205. package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
  206. package/src/modules/user-management/lib/types.ts +31 -0
  207. package/src/modules/user-management/module.ts +77 -0
  208. package/src/modules/user-management/permissions.ts +15 -0
  209. package/src/modules/user-management/tailor.config.ts +11 -0
  210. package/src/modules/user-management/testing/fixtures.ts +98 -0
  211. package/src/schemas.ts +25 -0
  212. package/src/testing.ts +10 -0
  213. package/src/util.ts +3 -0
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
3
+ import { createMockDb } from "../../testing/index";
4
+ import { DB } from "../generated/kysely-tailordb";
5
+ import {
6
+ InvalidEmailError,
7
+ MissingRequiredFieldError,
8
+ UserAlreadyExistsError,
9
+ } from "../lib/errors";
10
+ import { activeUser, baseAuditEvent, pendingUser } from "../testing/fixtures";
11
+ import { makeCreateUser } from "./createUser";
12
+
13
+ const createUser = makeCreateUser();
14
+
15
+ describe("createUser", () => {
16
+ const ctx: CommandContext = {
17
+ actorId: "test-actor",
18
+ permissions: ["user-management:createUser"],
19
+ };
20
+
21
+ // Error cases
22
+ it("throws when email is empty", async () => {
23
+ const { db } = createMockDb<DB>();
24
+
25
+ await expect(
26
+ createUser(db, { email: "", name: "Test User", actorId: "actor-1" }, ctx),
27
+ ).rejects.toBeInstanceOf(MissingRequiredFieldError);
28
+ });
29
+
30
+ it("throws when name is empty", async () => {
31
+ const { db } = createMockDb<DB>();
32
+
33
+ await expect(
34
+ createUser(db, { email: "test@example.com", name: "", actorId: "actor-1" }, ctx),
35
+ ).rejects.toBeInstanceOf(MissingRequiredFieldError);
36
+ });
37
+
38
+ it("throws when name is whitespace only", async () => {
39
+ const { db } = createMockDb<DB>();
40
+
41
+ await expect(
42
+ createUser(db, { email: "test@example.com", name: " ", actorId: "actor-1" }, ctx),
43
+ ).rejects.toBeInstanceOf(MissingRequiredFieldError);
44
+ });
45
+
46
+ it("throws when email format is invalid (no @)", async () => {
47
+ const { db } = createMockDb<DB>();
48
+
49
+ await expect(
50
+ createUser(db, { email: "invalidemail", name: "Test User", actorId: "actor-1" }, ctx),
51
+ ).rejects.toBeInstanceOf(InvalidEmailError);
52
+ });
53
+
54
+ it("throws when email format is invalid (no domain)", async () => {
55
+ const { db } = createMockDb<DB>();
56
+
57
+ await expect(
58
+ createUser(db, { email: "test@", name: "Test User", actorId: "actor-1" }, ctx),
59
+ ).rejects.toBeInstanceOf(InvalidEmailError);
60
+ });
61
+
62
+ it("throws when email already exists", async () => {
63
+ const { db, spies } = createMockDb<DB>();
64
+ spies.select.mockReturnValue(activeUser);
65
+
66
+ await expect(
67
+ createUser(db, { email: activeUser.email, name: "New User", actorId: "actor-1" }, ctx),
68
+ ).rejects.toBeInstanceOf(UserAlreadyExistsError);
69
+ });
70
+
71
+ // Success cases
72
+ it("creates user with PENDING status", async () => {
73
+ const { db, spies } = createMockDb<DB>();
74
+ const createdUser = {
75
+ ...pendingUser,
76
+ id: "new-user-id",
77
+ email: "new@example.com",
78
+ name: "New User",
79
+ };
80
+ const createdAuditEvent = {
81
+ ...baseAuditEvent,
82
+ id: "new-event-id",
83
+ eventType: "USER_CREATED" as const,
84
+ actorId: "actor-1",
85
+ targetId: "new-user-id",
86
+ targetType: "User",
87
+ };
88
+
89
+ spies.select.mockReturnValue(undefined); // No existing user
90
+ spies.insert.mockReturnValueOnce(createdUser).mockReturnValueOnce(createdAuditEvent);
91
+
92
+ const result = await createUser(
93
+ db,
94
+ {
95
+ email: "new@example.com",
96
+ name: "New User",
97
+ actorId: "actor-1",
98
+ },
99
+ ctx,
100
+ );
101
+
102
+ expect(result.user.email).toBe("new@example.com");
103
+ expect(result.user.name).toBe("New User");
104
+ expect(result.user.status).toBe("PENDING");
105
+ expect(spies.insert).toHaveBeenCalled();
106
+ });
107
+
108
+ it("logs USER_CREATED audit event", async () => {
109
+ const { db, spies } = createMockDb<DB>();
110
+ const createdUser = {
111
+ ...pendingUser,
112
+ id: "new-user-id",
113
+ };
114
+ const createdAuditEvent = {
115
+ ...baseAuditEvent,
116
+ eventType: "USER_CREATED" as const,
117
+ actorId: "actor-1",
118
+ targetId: "new-user-id",
119
+ };
120
+
121
+ spies.select.mockReturnValue(undefined);
122
+ spies.insert.mockReturnValueOnce(createdUser).mockReturnValueOnce(createdAuditEvent);
123
+
124
+ const result = await createUser(
125
+ db,
126
+ {
127
+ email: "test@example.com",
128
+ name: "Test User",
129
+ actorId: "actor-1",
130
+ },
131
+ ctx,
132
+ );
133
+
134
+ expect(result.auditEvent.eventType).toBe("USER_CREATED");
135
+ expect(result.auditEvent.actorId).toBe("actor-1");
136
+ expect(result.auditEvent.targetId).toBe(createdUser.id);
137
+ });
138
+
139
+ it("throws when permission is missing", async () => {
140
+ const { db } = createMockDb<DB>();
141
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
142
+ await expect(
143
+ createUser(db, { email: "test@example.com", name: "Test User", actorId: "actor-1" }, denied),
144
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
145
+ });
146
+
147
+ it("passes custom fields through to insert", async () => {
148
+ const createUserWithFields = makeCreateUser<{ department: string }>();
149
+ const { db, spies } = createMockDb<DB>();
150
+ const createdUser = {
151
+ ...pendingUser,
152
+ id: "new-user-id",
153
+ email: "new@example.com",
154
+ name: "New User",
155
+ department: "Engineering",
156
+ };
157
+ const createdAuditEvent = {
158
+ ...baseAuditEvent,
159
+ id: "new-event-id",
160
+ eventType: "USER_CREATED" as const,
161
+ actorId: "actor-1",
162
+ targetId: "new-user-id",
163
+ targetType: "User",
164
+ };
165
+
166
+ spies.select.mockReturnValue(undefined);
167
+ spies.insert.mockReturnValueOnce(createdUser).mockReturnValueOnce(createdAuditEvent);
168
+
169
+ const result = await createUserWithFields(
170
+ db,
171
+ {
172
+ email: "new@example.com",
173
+ name: "New User",
174
+ actorId: "actor-1",
175
+ department: "Engineering",
176
+ },
177
+ ctx,
178
+ );
179
+
180
+ expect(result.user.id).toBe("new-user-id");
181
+ expect(spies.insert).toHaveBeenCalledTimes(2);
182
+
183
+ // User insert includes custom field
184
+ expect(spies.values).toHaveBeenNthCalledWith(
185
+ 1,
186
+ expect.objectContaining({ department: "Engineering" }),
187
+ );
188
+
189
+ // Audit event payload includes custom field
190
+ expect(spies.values).toHaveBeenNthCalledWith(
191
+ 2,
192
+ expect.objectContaining({
193
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
194
+ payload: expect.stringContaining('"department":"Engineering"'),
195
+ }),
196
+ );
197
+ });
198
+ });
@@ -0,0 +1,85 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import {
4
+ InvalidEmailError,
5
+ MissingRequiredFieldError,
6
+ UserAlreadyExistsError,
7
+ } from "../lib/errors";
8
+ import { permissions } from "../permissions";
9
+
10
+ interface CreateUserInput {
11
+ email: string;
12
+ name: string;
13
+ actorId: string;
14
+ }
15
+
16
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17
+
18
+ /**
19
+ * Function: createUser
20
+ *
21
+ * Creates a new user account in PENDING status.
22
+ * Validates email format and uniqueness, and logs a USER_CREATED audit event.
23
+ */
24
+ export function makeCreateUser<CF extends Record<string, unknown>>() {
25
+ return defineCommand(permissions.createUser, async (db: DB, input: CreateUserInput & CF) => {
26
+ const { email, name, actorId, ...customFields } = input;
27
+
28
+ // 1. Validate email is provided
29
+ if (!email || email.trim() === "") {
30
+ throw new MissingRequiredFieldError("email");
31
+ }
32
+
33
+ // 2. Validate name is provided
34
+ if (!name || name.trim() === "") {
35
+ throw new MissingRequiredFieldError("name");
36
+ }
37
+
38
+ // 3. Validate email format
39
+ if (!EMAIL_PATTERN.test(email)) {
40
+ throw new InvalidEmailError(email);
41
+ }
42
+
43
+ // 4. Check if email already exists
44
+ const existingUser = await db
45
+ .selectFrom("User")
46
+ .selectAll()
47
+ .where("email", "=", email)
48
+ .executeTakeFirst();
49
+
50
+ if (existingUser) {
51
+ throw new UserAlreadyExistsError(email);
52
+ }
53
+
54
+ // 5. Create user with PENDING status
55
+ const user = await db
56
+ .insertInto("User")
57
+ .values({
58
+ ...(customFields as Record<string, unknown>),
59
+ email,
60
+ name,
61
+ status: "PENDING",
62
+ createdAt: new Date(),
63
+ updatedAt: null,
64
+ })
65
+ .returningAll()
66
+ .executeTakeFirst();
67
+
68
+ // 6. Log USER_CREATED audit event
69
+ const auditEvent = await db
70
+ .insertInto("AuditEvent")
71
+ .values({
72
+ eventType: "USER_CREATED",
73
+ actorId,
74
+ targetId: user!.id,
75
+ targetType: "User",
76
+ payload: JSON.stringify({ email, name, ...customFields }),
77
+ createdAt: new Date(),
78
+ updatedAt: null,
79
+ })
80
+ .returningAll()
81
+ .executeTakeFirst();
82
+
83
+ return { user: user!, auditEvent: auditEvent! };
84
+ });
85
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createMockDb, testNotFound, testPermissionDenied } from "../../testing/index";
3
+ import { type CommandContext } from "../../shared/internal";
4
+ import { DB } from "../generated/kysely-tailordb";
5
+ import { InvalidStatusTransitionError, UserNotFoundError } from "../lib/errors";
6
+ import { activeUser, baseAuditEvent, inactiveUser, pendingUser } from "../testing/fixtures";
7
+ import { deactivateUser } from "./deactivateUser";
8
+
9
+ describe("deactivateUser", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["user-management:deactivateUser"],
13
+ };
14
+
15
+ it(
16
+ "throws when user does not exist",
17
+ testNotFound(
18
+ deactivateUser,
19
+ { userId: "nonexistent-user", actorId: "actor-1" },
20
+ ctx,
21
+ UserNotFoundError,
22
+ ),
23
+ );
24
+
25
+ it("throws when user is PENDING", async () => {
26
+ const { db, spies } = createMockDb<DB>();
27
+ spies.select.mockReturnValue(pendingUser);
28
+
29
+ await expect(
30
+ deactivateUser(db, { userId: pendingUser.id, actorId: "actor-1" }, ctx),
31
+ ).rejects.toBeInstanceOf(InvalidStatusTransitionError);
32
+ });
33
+
34
+ it("throws when user is already INACTIVE", async () => {
35
+ const { db, spies } = createMockDb<DB>();
36
+ spies.select.mockReturnValue(inactiveUser);
37
+
38
+ await expect(
39
+ deactivateUser(db, { userId: inactiveUser.id, actorId: "actor-1" }, ctx),
40
+ ).rejects.toBeInstanceOf(InvalidStatusTransitionError);
41
+ });
42
+
43
+ // Success cases
44
+ it("deactivates ACTIVE user to INACTIVE", async () => {
45
+ const { db, spies } = createMockDb<DB>();
46
+ const deactivatedUser = {
47
+ ...activeUser,
48
+ status: "INACTIVE" as const,
49
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
50
+ };
51
+ const createdAuditEvent = {
52
+ ...baseAuditEvent,
53
+ eventType: "USER_DEACTIVATED" as const,
54
+ actorId: "actor-1",
55
+ targetId: activeUser.id,
56
+ };
57
+
58
+ spies.select.mockReturnValue(activeUser);
59
+ spies.update.mockReturnValue(deactivatedUser);
60
+ spies.insert.mockReturnValue(createdAuditEvent);
61
+
62
+ const result = await deactivateUser(
63
+ db,
64
+ {
65
+ userId: activeUser.id,
66
+ actorId: "actor-1",
67
+ },
68
+ ctx,
69
+ );
70
+
71
+ expect(result.user.status).toBe("INACTIVE");
72
+ expect(result.user.updatedAt).not.toBeNull();
73
+ expect(spies.update).toHaveBeenCalled();
74
+ });
75
+
76
+ it("logs USER_DEACTIVATED audit event", async () => {
77
+ const { db, spies } = createMockDb<DB>();
78
+ const deactivatedUser = {
79
+ ...activeUser,
80
+ status: "INACTIVE" as const,
81
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
82
+ };
83
+ const createdAuditEvent = {
84
+ ...baseAuditEvent,
85
+ eventType: "USER_DEACTIVATED" as const,
86
+ actorId: "actor-1",
87
+ targetId: activeUser.id,
88
+ };
89
+
90
+ spies.select.mockReturnValue(activeUser);
91
+ spies.update.mockReturnValue(deactivatedUser);
92
+ spies.insert.mockReturnValue(createdAuditEvent);
93
+
94
+ const result = await deactivateUser(
95
+ db,
96
+ {
97
+ userId: activeUser.id,
98
+ actorId: "actor-1",
99
+ },
100
+ ctx,
101
+ );
102
+
103
+ expect(result.auditEvent.eventType).toBe("USER_DEACTIVATED");
104
+ expect(result.auditEvent.actorId).toBe("actor-1");
105
+ expect(result.auditEvent.targetId).toBe(activeUser.id);
106
+ });
107
+
108
+ it(
109
+ "throws when permission is missing",
110
+ testPermissionDenied(deactivateUser, { userId: "some-user", actorId: "actor-1" }),
111
+ );
112
+ });
@@ -0,0 +1,67 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { InvalidStatusTransitionError, UserNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface DeactivateUserInput {
7
+ userId: string;
8
+ actorId: string;
9
+ from?: string[];
10
+ }
11
+
12
+ /**
13
+ * Function: deactivateUser
14
+ *
15
+ * Transitions a user from ACTIVE to INACTIVE status.
16
+ * Role assignments are preserved for audit purposes.
17
+ */
18
+ export const deactivateUser = defineCommand(
19
+ permissions.deactivateUser,
20
+ async (db: DB, input: DeactivateUserInput) => {
21
+ // 1. Find user by ID
22
+ const user = await db
23
+ .selectFrom("User")
24
+ .selectAll()
25
+ .where("id", "=", input.userId)
26
+ .executeTakeFirst();
27
+
28
+ // 2. If not found, throw error
29
+ if (!user) {
30
+ throw new UserNotFoundError(input.userId);
31
+ }
32
+
33
+ // 3. Validate status transition
34
+ const validFromStatuses = input.from ?? ["ACTIVE"];
35
+ if (!validFromStatuses.includes(user.status)) {
36
+ throw new InvalidStatusTransitionError(user.status, "INACTIVE");
37
+ }
38
+
39
+ // 4. Update status to INACTIVE
40
+ const updatedUser = await db
41
+ .updateTable("User")
42
+ .set({
43
+ status: "INACTIVE",
44
+ updatedAt: new Date(),
45
+ })
46
+ .where("id", "=", input.userId)
47
+ .returningAll()
48
+ .executeTakeFirst();
49
+
50
+ // 5. Log USER_DEACTIVATED audit event
51
+ const auditEvent = await db
52
+ .insertInto("AuditEvent")
53
+ .values({
54
+ eventType: "USER_DEACTIVATED",
55
+ actorId: input.actorId,
56
+ targetId: input.userId,
57
+ targetType: "User",
58
+ payload: JSON.stringify({ previousStatus: user.status }),
59
+ createdAt: new Date(),
60
+ updatedAt: null,
61
+ })
62
+ .returningAll()
63
+ .executeTakeFirst();
64
+
65
+ return { user: updatedUser!, auditEvent: auditEvent! };
66
+ },
67
+ );
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
3
+ import { createMockDb } from "../../testing/index";
4
+ import { DB } from "../generated/kysely-tailordb";
5
+ import { InvalidEventTypeError, MissingRequiredFieldError } from "../lib/errors";
6
+ import { baseAuditEvent } from "../testing/fixtures";
7
+ import { logAuditEvent } from "./logAuditEvent";
8
+
9
+ describe("logAuditEvent", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["user-management:logAuditEvent"],
13
+ };
14
+
15
+ it("throws when actorId is empty", async () => {
16
+ const { db } = createMockDb<DB>();
17
+
18
+ await expect(
19
+ logAuditEvent(
20
+ db,
21
+ {
22
+ eventType: "USER_CREATED",
23
+ actorId: "",
24
+ },
25
+ ctx,
26
+ ),
27
+ ).rejects.toBeInstanceOf(MissingRequiredFieldError);
28
+ });
29
+
30
+ it("throws when eventType is invalid", async () => {
31
+ const { db } = createMockDb<DB>();
32
+
33
+ await expect(
34
+ logAuditEvent(
35
+ db,
36
+ {
37
+ eventType: "INVALID_EVENT" as "USER_CREATED",
38
+ actorId: "user-1",
39
+ },
40
+ ctx,
41
+ ),
42
+ ).rejects.toBeInstanceOf(InvalidEventTypeError);
43
+ });
44
+
45
+ it("creates audit event with minimal fields", async () => {
46
+ const { db, spies } = createMockDb<DB>();
47
+ const createdEvent = {
48
+ ...baseAuditEvent,
49
+ id: "new-event-id",
50
+ eventType: "USER_CREATED" as const,
51
+ actorId: "user-1",
52
+ targetId: null,
53
+ targetType: null,
54
+ payload: null,
55
+ };
56
+
57
+ spies.insert.mockReturnValue(createdEvent);
58
+
59
+ const result = await logAuditEvent(
60
+ db,
61
+ {
62
+ eventType: "USER_CREATED",
63
+ actorId: "user-1",
64
+ },
65
+ ctx,
66
+ );
67
+
68
+ expect(result.auditEvent.eventType).toBe("USER_CREATED");
69
+ expect(result.auditEvent.actorId).toBe("user-1");
70
+ expect(spies.insert).toHaveBeenCalled();
71
+ });
72
+
73
+ it("creates audit event with target info", async () => {
74
+ const { db, spies } = createMockDb<DB>();
75
+ const createdEvent = {
76
+ ...baseAuditEvent,
77
+ id: "new-event-id",
78
+ eventType: "ROLE_ASSIGNED" as const,
79
+ actorId: "user-1",
80
+ targetId: "user-2",
81
+ targetType: "User",
82
+ payload: null,
83
+ };
84
+
85
+ spies.insert.mockReturnValue(createdEvent);
86
+
87
+ const result = await logAuditEvent(
88
+ db,
89
+ {
90
+ eventType: "ROLE_ASSIGNED",
91
+ actorId: "user-1",
92
+ targetId: "user-2",
93
+ targetType: "User",
94
+ },
95
+ ctx,
96
+ );
97
+
98
+ expect(result.auditEvent.eventType).toBe("ROLE_ASSIGNED");
99
+ expect(result.auditEvent.targetId).toBe("user-2");
100
+ expect(result.auditEvent.targetType).toBe("User");
101
+ });
102
+
103
+ it("creates audit event with payload", async () => {
104
+ const { db, spies } = createMockDb<DB>();
105
+ const payload = JSON.stringify({ roleId: "role-1", userId: "user-2" });
106
+ const createdEvent = {
107
+ ...baseAuditEvent,
108
+ id: "new-event-id",
109
+ eventType: "ROLE_ASSIGNED" as const,
110
+ actorId: "user-1",
111
+ targetId: "user-2",
112
+ targetType: "User",
113
+ payload,
114
+ };
115
+
116
+ spies.insert.mockReturnValue(createdEvent);
117
+
118
+ const result = await logAuditEvent(
119
+ db,
120
+ {
121
+ eventType: "ROLE_ASSIGNED",
122
+ actorId: "user-1",
123
+ targetId: "user-2",
124
+ targetType: "User",
125
+ payload,
126
+ },
127
+ ctx,
128
+ );
129
+
130
+ expect(result.auditEvent.payload).toBe(payload);
131
+ });
132
+
133
+ it("creates audit event for each valid event type", async () => {
134
+ const eventTypes = [
135
+ "USER_CREATED",
136
+ "USER_ACTIVATED",
137
+ "USER_DEACTIVATED",
138
+ "USER_REACTIVATED",
139
+ "ROLE_ASSIGNED",
140
+ "ROLE_REVOKED",
141
+ "PERMISSION_ASSIGNED",
142
+ "PERMISSION_REVOKED",
143
+ ] as const;
144
+
145
+ for (const eventType of eventTypes) {
146
+ const { db, spies } = createMockDb<DB>();
147
+ const createdEvent = {
148
+ ...baseAuditEvent,
149
+ id: "new-event-id",
150
+ eventType,
151
+ actorId: "user-1",
152
+ targetId: null,
153
+ targetType: null,
154
+ payload: null,
155
+ };
156
+
157
+ spies.insert.mockReturnValue(createdEvent);
158
+
159
+ const result = await logAuditEvent(
160
+ db,
161
+ {
162
+ eventType,
163
+ actorId: "user-1",
164
+ },
165
+ ctx,
166
+ );
167
+
168
+ expect(result.auditEvent.eventType).toBe(eventType);
169
+ }
170
+ });
171
+
172
+ it("throws when permission is missing", async () => {
173
+ const { db } = createMockDb<DB>();
174
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
175
+ await expect(
176
+ logAuditEvent(db, { eventType: "USER_CREATED", actorId: "user-1" }, denied),
177
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
178
+ });
179
+ });
@@ -0,0 +1,59 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { AuditEventEventType } from "../generated/enums";
4
+ import { InvalidEventTypeError, MissingRequiredFieldError } from "../lib/errors";
5
+ import { permissions } from "../permissions";
6
+
7
+ export interface LogAuditEventInput {
8
+ eventType: AuditEventEventType;
9
+ actorId: string;
10
+ targetId?: string;
11
+ targetType?: string;
12
+ payload?: string;
13
+ }
14
+
15
+ const VALID_EVENT_TYPES = new Set<string>([
16
+ "USER_CREATED",
17
+ "USER_ACTIVATED",
18
+ "USER_DEACTIVATED",
19
+ "USER_REACTIVATED",
20
+ "ROLE_ASSIGNED",
21
+ "ROLE_REVOKED",
22
+ "PERMISSION_ASSIGNED",
23
+ "PERMISSION_REVOKED",
24
+ ]);
25
+
26
+ /**
27
+ * Function: logAuditEvent
28
+ *
29
+ * Creates an immutable audit record of a security-relevant event.
30
+ * Records are created with timestamp and cannot be modified or deleted.
31
+ */
32
+ export const logAuditEvent = defineCommand(
33
+ permissions.logAuditEvent,
34
+ async (db: DB, input: LogAuditEventInput) => {
35
+ if (!input.actorId || input.actorId.trim() === "") {
36
+ throw new MissingRequiredFieldError("actorId");
37
+ }
38
+
39
+ if (!VALID_EVENT_TYPES.has(input.eventType)) {
40
+ throw new InvalidEventTypeError(input.eventType);
41
+ }
42
+
43
+ const auditEvent = await db
44
+ .insertInto("AuditEvent")
45
+ .values({
46
+ eventType: input.eventType,
47
+ actorId: input.actorId,
48
+ targetId: input.targetId ?? null,
49
+ targetType: input.targetType ?? null,
50
+ payload: input.payload ?? null,
51
+ createdAt: new Date(),
52
+ updatedAt: null,
53
+ })
54
+ .returningAll()
55
+ .executeTakeFirst();
56
+
57
+ return { auditEvent: auditEvent! };
58
+ },
59
+ );