@tailor-platform/erp-kit 0.0.1 → 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 (231) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -28
  4. package/dist/cli.js +914 -0
  5. package/package.json +67 -8
  6. package/schemas/app-compose/actors.yml +34 -0
  7. package/schemas/app-compose/business-flow.yml +50 -0
  8. package/schemas/app-compose/requirements.yml +33 -0
  9. package/schemas/app-compose/resolver.yml +47 -0
  10. package/schemas/app-compose/screen.yml +81 -0
  11. package/schemas/app-compose/story.yml +67 -0
  12. package/schemas/module/command.yml +52 -0
  13. package/schemas/module/feature.yml +58 -0
  14. package/schemas/module/model.yml +70 -0
  15. package/schemas/module/module.yml +50 -0
  16. package/skills/1-module-docs/SKILL.md +111 -0
  17. package/skills/1-module-docs/references/structure.md +22 -0
  18. package/skills/2-module-feature-breakdown/SKILL.md +72 -0
  19. package/skills/2-module-feature-breakdown/references/commands.md +48 -0
  20. package/skills/2-module-feature-breakdown/references/models.md +29 -0
  21. package/skills/2-module-feature-breakdown/references/structure.md +22 -0
  22. package/skills/3-module-doc-review/SKILL.md +236 -0
  23. package/skills/3-module-doc-review/references/commands.md +54 -0
  24. package/skills/3-module-doc-review/references/models.md +29 -0
  25. package/skills/3-module-doc-review/references/testing.md +37 -0
  26. package/skills/4-module-tdd-implementation/SKILL.md +74 -0
  27. package/skills/4-module-tdd-implementation/references/commands.md +45 -0
  28. package/skills/4-module-tdd-implementation/references/db-relations.md +69 -0
  29. package/skills/4-module-tdd-implementation/references/errors.md +7 -0
  30. package/skills/4-module-tdd-implementation/references/exports.md +8 -0
  31. package/skills/4-module-tdd-implementation/references/models.md +30 -0
  32. package/skills/4-module-tdd-implementation/references/structure.md +22 -0
  33. package/skills/4-module-tdd-implementation/references/testing.md +37 -0
  34. package/skills/5-module-implementation-review/SKILL.md +408 -0
  35. package/skills/5-module-implementation-review/references/commands.md +45 -0
  36. package/skills/5-module-implementation-review/references/errors.md +7 -0
  37. package/skills/5-module-implementation-review/references/exports.md +8 -0
  38. package/skills/5-module-implementation-review/references/models.md +30 -0
  39. package/skills/5-module-implementation-review/references/testing.md +29 -0
  40. package/skills/app-compose-1-requirement-analysis/SKILL.md +89 -0
  41. package/skills/app-compose-1-requirement-analysis/references/structure.md +27 -0
  42. package/skills/app-compose-2-requirements-breakdown/SKILL.md +95 -0
  43. package/skills/app-compose-2-requirements-breakdown/references/screen-detailview.md +106 -0
  44. package/skills/app-compose-2-requirements-breakdown/references/screen-form.md +139 -0
  45. package/skills/app-compose-2-requirements-breakdown/references/screen-listview.md +153 -0
  46. package/skills/app-compose-2-requirements-breakdown/references/structure.md +27 -0
  47. package/skills/app-compose-3-doc-review/SKILL.md +116 -0
  48. package/skills/app-compose-3-doc-review/references/structure.md +27 -0
  49. package/skills/app-compose-4-design-mock/SKILL.md +256 -0
  50. package/skills/app-compose-4-design-mock/references/component.md +50 -0
  51. package/skills/app-compose-4-design-mock/references/screen-detailview.md +106 -0
  52. package/skills/app-compose-4-design-mock/references/screen-form.md +139 -0
  53. package/skills/app-compose-4-design-mock/references/screen-listview.md +153 -0
  54. package/skills/app-compose-4-design-mock/references/structure.md +27 -0
  55. package/skills/app-compose-5-design-mock-review/SKILL.md +290 -0
  56. package/skills/app-compose-5-design-mock-review/references/component.md +50 -0
  57. package/skills/app-compose-5-design-mock-review/references/screen-detailview.md +106 -0
  58. package/skills/app-compose-5-design-mock-review/references/screen-form.md +139 -0
  59. package/skills/app-compose-5-design-mock-review/references/screen-listview.md +153 -0
  60. package/skills/app-compose-6-implementation-spec/SKILL.md +127 -0
  61. package/skills/app-compose-6-implementation-spec/references/auth.md +72 -0
  62. package/skills/app-compose-6-implementation-spec/references/structure.md +27 -0
  63. package/skills/mock-scenario/SKILL.md +118 -0
  64. package/src/app.ts +1 -0
  65. package/src/cli.ts +120 -0
  66. package/src/commands/check.test.ts +30 -0
  67. package/src/commands/check.ts +66 -0
  68. package/src/commands/init.test.ts +88 -0
  69. package/src/commands/init.ts +120 -0
  70. package/src/commands/mock/index.ts +53 -0
  71. package/src/commands/mock/start.ts +179 -0
  72. package/src/commands/mock/validate.test.ts +185 -0
  73. package/src/commands/mock/validate.ts +198 -0
  74. package/src/commands/scaffold.test.ts +76 -0
  75. package/src/commands/scaffold.ts +119 -0
  76. package/src/commands/sync-check.test.ts +125 -0
  77. package/src/commands/sync-check.ts +182 -0
  78. package/src/integration.test.ts +63 -0
  79. package/src/mdschema.ts +48 -0
  80. package/src/mockServer.ts +55 -0
  81. package/src/module.ts +86 -0
  82. package/src/modules/accounting/.gitkeep +0 -0
  83. package/src/modules/coa-management/.gitkeep +0 -0
  84. package/src/modules/inventory/.gitkeep +0 -0
  85. package/src/modules/manufacturing/.gitkeep +0 -0
  86. package/src/modules/primitives/README.md +39 -0
  87. package/src/modules/primitives/command/activateCategory.test.ts +75 -0
  88. package/src/modules/primitives/command/activateCategory.ts +50 -0
  89. package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
  90. package/src/modules/primitives/command/activateCurrency.ts +50 -0
  91. package/src/modules/primitives/command/activateUnit.test.ts +53 -0
  92. package/src/modules/primitives/command/activateUnit.ts +50 -0
  93. package/src/modules/primitives/command/convertAmount.test.ts +275 -0
  94. package/src/modules/primitives/command/convertAmount.ts +126 -0
  95. package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
  96. package/src/modules/primitives/command/convertQuantity.ts +73 -0
  97. package/src/modules/primitives/command/createCategory.test.ts +126 -0
  98. package/src/modules/primitives/command/createCategory.ts +89 -0
  99. package/src/modules/primitives/command/createCurrency.test.ts +191 -0
  100. package/src/modules/primitives/command/createCurrency.ts +77 -0
  101. package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
  102. package/src/modules/primitives/command/createExchangeRate.ts +91 -0
  103. package/src/modules/primitives/command/createUnit.test.ts +214 -0
  104. package/src/modules/primitives/command/createUnit.ts +88 -0
  105. package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
  106. package/src/modules/primitives/command/deactivateCategory.ts +62 -0
  107. package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
  108. package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
  109. package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
  110. package/src/modules/primitives/command/deactivateUnit.ts +62 -0
  111. package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
  112. package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
  113. package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
  114. package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
  115. package/src/modules/primitives/db/currency.ts +30 -0
  116. package/src/modules/primitives/db/exchangeRate.ts +28 -0
  117. package/src/modules/primitives/db/unit.ts +32 -0
  118. package/src/modules/primitives/db/uomCategory.ts +32 -0
  119. package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
  120. package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
  121. package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
  122. package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
  123. package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
  124. package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
  125. package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
  126. package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
  127. package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
  128. package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
  129. package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
  130. package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
  131. package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
  132. package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
  133. package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
  134. package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
  135. package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
  136. package/src/modules/primitives/docs/features/uom-categories.md +52 -0
  137. package/src/modules/primitives/docs/models/Currency.md +45 -0
  138. package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
  139. package/src/modules/primitives/docs/models/Unit.md +46 -0
  140. package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
  141. package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
  142. package/src/modules/primitives/index.ts +40 -0
  143. package/src/modules/primitives/lib/errors.ts +138 -0
  144. package/src/modules/primitives/lib/types.ts +20 -0
  145. package/src/modules/primitives/module.ts +66 -0
  146. package/src/modules/primitives/permissions.ts +18 -0
  147. package/src/modules/primitives/tailor.config.ts +11 -0
  148. package/src/modules/primitives/testing/fixtures.ts +161 -0
  149. package/src/modules/product-management/.gitkeep +0 -0
  150. package/src/modules/purchase/.gitkeep +0 -0
  151. package/src/modules/sales/.gitkeep +0 -0
  152. package/src/modules/shared/createContext.test.ts +39 -0
  153. package/src/modules/shared/createContext.ts +15 -0
  154. package/src/modules/shared/defineCommand.test.ts +42 -0
  155. package/src/modules/shared/defineCommand.ts +19 -0
  156. package/src/modules/shared/definePermissions.test.ts +146 -0
  157. package/src/modules/shared/definePermissions.ts +94 -0
  158. package/src/modules/shared/entityTypes.ts +15 -0
  159. package/src/modules/shared/errors.ts +22 -0
  160. package/src/modules/shared/index.ts +1 -0
  161. package/src/modules/shared/internal.ts +13 -0
  162. package/src/modules/shared/requirePermission.test.ts +47 -0
  163. package/src/modules/shared/requirePermission.ts +8 -0
  164. package/src/modules/shared/types.ts +4 -0
  165. package/src/modules/supplier-management/.gitkeep +0 -0
  166. package/src/modules/supplier-portal/.gitkeep +0 -0
  167. package/src/modules/testing/index.ts +120 -0
  168. package/src/modules/user-management/README.md +38 -0
  169. package/src/modules/user-management/command/activateUser.test.ts +112 -0
  170. package/src/modules/user-management/command/activateUser.ts +67 -0
  171. package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
  172. package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
  173. package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
  174. package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
  175. package/src/modules/user-management/command/createPermission.test.ts +143 -0
  176. package/src/modules/user-management/command/createPermission.ts +66 -0
  177. package/src/modules/user-management/command/createRole.test.ts +115 -0
  178. package/src/modules/user-management/command/createRole.ts +52 -0
  179. package/src/modules/user-management/command/createUser.test.ts +198 -0
  180. package/src/modules/user-management/command/createUser.ts +85 -0
  181. package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
  182. package/src/modules/user-management/command/deactivateUser.ts +67 -0
  183. package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
  184. package/src/modules/user-management/command/logAuditEvent.ts +59 -0
  185. package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
  186. package/src/modules/user-management/command/reactivateUser.ts +67 -0
  187. package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
  188. package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
  189. package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
  190. package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
  191. package/src/modules/user-management/db/auditEvent.ts +47 -0
  192. package/src/modules/user-management/db/permission.ts +31 -0
  193. package/src/modules/user-management/db/role.ts +28 -0
  194. package/src/modules/user-management/db/rolePermission.ts +44 -0
  195. package/src/modules/user-management/db/user.ts +38 -0
  196. package/src/modules/user-management/db/userRole.ts +44 -0
  197. package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
  198. package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
  199. package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
  200. package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
  201. package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
  202. package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
  203. package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
  204. package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
  205. package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
  206. package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
  207. package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
  208. package/src/modules/user-management/docs/features/audit-trail.md +80 -0
  209. package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
  210. package/src/modules/user-management/docs/features/user-account-management.md +64 -0
  211. package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
  212. package/src/modules/user-management/docs/models/Permission.md +31 -0
  213. package/src/modules/user-management/docs/models/Role.md +31 -0
  214. package/src/modules/user-management/docs/models/RolePermission.md +33 -0
  215. package/src/modules/user-management/docs/models/User.md +47 -0
  216. package/src/modules/user-management/docs/models/UserRole.md +34 -0
  217. package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
  218. package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
  219. package/src/modules/user-management/generated/enums.ts +24 -0
  220. package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
  221. package/src/modules/user-management/index.ts +32 -0
  222. package/src/modules/user-management/lib/errors.ts +81 -0
  223. package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
  224. package/src/modules/user-management/lib/types.ts +31 -0
  225. package/src/modules/user-management/module.ts +77 -0
  226. package/src/modules/user-management/permissions.ts +15 -0
  227. package/src/modules/user-management/tailor.config.ts +11 -0
  228. package/src/modules/user-management/testing/fixtures.ts +98 -0
  229. package/src/schemas.ts +25 -0
  230. package/src/testing.ts +10 -0
  231. package/src/util.ts +3 -0
@@ -0,0 +1,162 @@
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 { RoleNotFoundError, UserNotActiveError, UserNotFoundError } from "../lib/errors";
6
+ import {
7
+ activeUser,
8
+ baseAuditEvent,
9
+ baseRole,
10
+ baseUserRole,
11
+ inactiveUser,
12
+ pendingUser,
13
+ } from "../testing/fixtures";
14
+ import { assignRoleToUser } from "./assignRoleToUser";
15
+
16
+ describe("assignRoleToUser", () => {
17
+ const ctx: CommandContext = {
18
+ actorId: "test-actor",
19
+ permissions: ["user-management:assignRoleToUser"],
20
+ };
21
+
22
+ it("throws when user does not exist", async () => {
23
+ const { db, spies } = createMockDb<DB>();
24
+ spies.select.mockReturnValue(undefined);
25
+
26
+ await expect(
27
+ assignRoleToUser(
28
+ db,
29
+ {
30
+ userId: "nonexistent-user",
31
+ roleId: baseRole.id,
32
+ actorId: "actor-1",
33
+ },
34
+ ctx,
35
+ ),
36
+ ).rejects.toBeInstanceOf(UserNotFoundError);
37
+ });
38
+
39
+ it("throws when role does not exist", async () => {
40
+ const { db, spies } = createMockDb<DB>();
41
+ spies.select.mockReturnValueOnce(activeUser).mockReturnValueOnce(undefined);
42
+
43
+ await expect(
44
+ assignRoleToUser(
45
+ db,
46
+ {
47
+ userId: activeUser.id,
48
+ roleId: "nonexistent-role",
49
+ actorId: "actor-1",
50
+ },
51
+ ctx,
52
+ ),
53
+ ).rejects.toBeInstanceOf(RoleNotFoundError);
54
+ });
55
+
56
+ it("throws when user is PENDING", async () => {
57
+ const { db, spies } = createMockDb<DB>();
58
+ spies.select.mockReturnValueOnce(pendingUser).mockReturnValueOnce(baseRole);
59
+
60
+ await expect(
61
+ assignRoleToUser(
62
+ db,
63
+ {
64
+ userId: pendingUser.id,
65
+ roleId: baseRole.id,
66
+ actorId: "actor-1",
67
+ },
68
+ ctx,
69
+ ),
70
+ ).rejects.toBeInstanceOf(UserNotActiveError);
71
+ });
72
+
73
+ it("throws when user is INACTIVE", async () => {
74
+ const { db, spies } = createMockDb<DB>();
75
+ spies.select.mockReturnValueOnce(inactiveUser).mockReturnValueOnce(baseRole);
76
+
77
+ await expect(
78
+ assignRoleToUser(
79
+ db,
80
+ {
81
+ userId: inactiveUser.id,
82
+ roleId: baseRole.id,
83
+ actorId: "actor-1",
84
+ },
85
+ ctx,
86
+ ),
87
+ ).rejects.toBeInstanceOf(UserNotActiveError);
88
+ });
89
+
90
+ it("returns success when assignment already exists (idempotent)", async () => {
91
+ const { db, spies } = createMockDb<DB>();
92
+ const updatedUser = { ...activeUser, permissions: ["orders:read"] };
93
+ spies.select
94
+ .mockReturnValueOnce(activeUser)
95
+ .mockReturnValueOnce(baseRole)
96
+ .mockReturnValueOnce(baseUserRole) // Assignment exists
97
+ .mockReturnValueOnce([{ key: "orders:read" }]); // Permission keys from recompute
98
+ spies.update.mockReturnValueOnce(updatedUser);
99
+
100
+ const result = await assignRoleToUser(
101
+ db,
102
+ {
103
+ userId: activeUser.id,
104
+ roleId: baseRole.id,
105
+ actorId: "actor-1",
106
+ },
107
+ ctx,
108
+ );
109
+
110
+ expect(result.userRole).toEqual(baseUserRole);
111
+ expect(result.user.permissions).toEqual(["orders:read"]);
112
+ expect(spies.insert).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("creates UserRole and logs ROLE_ASSIGNED event", async () => {
116
+ const { db, spies } = createMockDb<DB>();
117
+ const newUserRole = {
118
+ ...baseUserRole,
119
+ id: "new-user-role-id",
120
+ };
121
+ const createdAuditEvent = {
122
+ ...baseAuditEvent,
123
+ eventType: "ROLE_ASSIGNED" as const,
124
+ actorId: "actor-1",
125
+ targetId: activeUser.id,
126
+ targetType: "User",
127
+ };
128
+ const updatedUser = { ...activeUser, permissions: ["orders:read"] };
129
+
130
+ spies.select
131
+ .mockReturnValueOnce(activeUser)
132
+ .mockReturnValueOnce(baseRole)
133
+ .mockReturnValueOnce(undefined) // No existing assignment
134
+ .mockReturnValueOnce([{ key: "orders:read" }]); // Permission keys from recompute
135
+ spies.insert.mockReturnValueOnce(newUserRole).mockReturnValueOnce(createdAuditEvent);
136
+ spies.update.mockReturnValueOnce(updatedUser);
137
+
138
+ const result = await assignRoleToUser(
139
+ db,
140
+ {
141
+ userId: activeUser.id,
142
+ roleId: baseRole.id,
143
+ actorId: "actor-1",
144
+ },
145
+ ctx,
146
+ );
147
+
148
+ expect(result.userRole.userId).toBe(activeUser.id);
149
+ expect(result.userRole.roleId).toBe(baseRole.id);
150
+ expect(result.auditEvent?.eventType).toBe("ROLE_ASSIGNED");
151
+ expect(result.user.permissions).toEqual(["orders:read"]);
152
+ expect(spies.insert).toHaveBeenCalled();
153
+ });
154
+
155
+ it("throws when permission is missing", async () => {
156
+ const { db } = createMockDb<DB>();
157
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
158
+ await expect(
159
+ assignRoleToUser(db, { userId: "user-1", roleId: "role-1", actorId: "actor-1" }, denied),
160
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
161
+ });
162
+ });
@@ -0,0 +1,93 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { RoleNotFoundError, UserNotActiveError, UserNotFoundError } from "../lib/errors";
4
+ import { recomputeUserPermissions } from "../lib/recomputeUserPermissions";
5
+ import { permissions } from "../permissions";
6
+
7
+ export interface AssignRoleToUserInput {
8
+ userId: string;
9
+ roleId: string;
10
+ actorId: string;
11
+ }
12
+
13
+ /**
14
+ * Function: assignRoleToUser
15
+ *
16
+ * Assigns a role to a user. Only ACTIVE users can receive role assignments.
17
+ * Operation is idempotent - assigning the same role twice does not create
18
+ * duplicate records or error.
19
+ */
20
+ export const assignRoleToUser = defineCommand(
21
+ permissions.assignRoleToUser,
22
+ async (db: DB, input: AssignRoleToUserInput) => {
23
+ const user = await db
24
+ .selectFrom("User")
25
+ .selectAll()
26
+ .where("id", "=", input.userId)
27
+ .executeTakeFirst();
28
+
29
+ if (!user) {
30
+ throw new UserNotFoundError(input.userId);
31
+ }
32
+
33
+ const role = await db
34
+ .selectFrom("Role")
35
+ .selectAll()
36
+ .where("id", "=", input.roleId)
37
+ .executeTakeFirst();
38
+
39
+ if (!role) {
40
+ throw new RoleNotFoundError(input.roleId);
41
+ }
42
+
43
+ if (user.status !== "ACTIVE") {
44
+ throw new UserNotActiveError(input.userId, user.status);
45
+ }
46
+
47
+ // Idempotent: return existing assignment, recompute permissions for consistency
48
+ const existingAssignment = await db
49
+ .selectFrom("UserRole")
50
+ .selectAll()
51
+ .where("userId", "=", input.userId)
52
+ .where("roleId", "=", input.roleId)
53
+ .executeTakeFirst();
54
+
55
+ if (existingAssignment) {
56
+ const updatedUser = await recomputeUserPermissions(db, input.userId);
57
+ return { userRole: existingAssignment, user: updatedUser };
58
+ }
59
+
60
+ const userRole = await db
61
+ .insertInto("UserRole")
62
+ .values({
63
+ userId: input.userId,
64
+ roleId: input.roleId,
65
+ createdAt: new Date(),
66
+ updatedAt: null,
67
+ })
68
+ .returningAll()
69
+ .executeTakeFirst();
70
+
71
+ const auditEvent = await db
72
+ .insertInto("AuditEvent")
73
+ .values({
74
+ eventType: "ROLE_ASSIGNED",
75
+ actorId: input.actorId,
76
+ targetId: input.userId,
77
+ targetType: "User",
78
+ payload: JSON.stringify({
79
+ userId: input.userId,
80
+ roleId: input.roleId,
81
+ roleName: role.name,
82
+ }),
83
+ createdAt: new Date(),
84
+ updatedAt: null,
85
+ })
86
+ .returningAll()
87
+ .executeTakeFirst();
88
+
89
+ const updatedUser = await recomputeUserPermissions(db, input.userId);
90
+
91
+ return { userRole: userRole!, user: updatedUser, auditEvent: auditEvent! };
92
+ },
93
+ );
@@ -0,0 +1,143 @@
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
+ DuplicatePermissionKeyError,
7
+ InvalidPermissionKeyFormatError,
8
+ MissingRequiredFieldError,
9
+ } from "../lib/errors";
10
+ import { basePermission } from "../testing/fixtures";
11
+ import { makeCreatePermission } from "./createPermission";
12
+
13
+ const createPermission = makeCreatePermission();
14
+
15
+ describe("createPermission", () => {
16
+ const ctx: CommandContext = {
17
+ actorId: "test-actor",
18
+ permissions: ["user-management:createPermission"],
19
+ };
20
+
21
+ // Error cases
22
+ it("throws when key is empty", async () => {
23
+ const { db } = createMockDb<DB>();
24
+
25
+ await expect(createPermission(db, { key: "" }, ctx)).rejects.toBeInstanceOf(
26
+ MissingRequiredFieldError,
27
+ );
28
+ });
29
+
30
+ it("throws when key format is invalid (no colon)", async () => {
31
+ const { db } = createMockDb<DB>();
32
+
33
+ await expect(createPermission(db, { key: "invalidkey" }, ctx)).rejects.toBeInstanceOf(
34
+ InvalidPermissionKeyFormatError,
35
+ );
36
+ });
37
+
38
+ it("throws when key format is invalid (multiple colons)", async () => {
39
+ const { db } = createMockDb<DB>();
40
+
41
+ await expect(
42
+ createPermission(db, { key: "resource:action:extra" }, ctx),
43
+ ).rejects.toBeInstanceOf(InvalidPermissionKeyFormatError);
44
+ });
45
+
46
+ it("throws when key format is invalid (empty resource)", async () => {
47
+ const { db } = createMockDb<DB>();
48
+
49
+ await expect(createPermission(db, { key: ":action" }, ctx)).rejects.toBeInstanceOf(
50
+ InvalidPermissionKeyFormatError,
51
+ );
52
+ });
53
+
54
+ it("throws when key format is invalid (empty action)", async () => {
55
+ const { db } = createMockDb<DB>();
56
+
57
+ await expect(createPermission(db, { key: "resource:" }, ctx)).rejects.toBeInstanceOf(
58
+ InvalidPermissionKeyFormatError,
59
+ );
60
+ });
61
+
62
+ it("throws when permission key already exists", async () => {
63
+ const { db, spies } = createMockDb<DB>();
64
+ spies.select.mockReturnValue(basePermission);
65
+
66
+ await expect(createPermission(db, { key: basePermission.key }, ctx)).rejects.toBeInstanceOf(
67
+ DuplicatePermissionKeyError,
68
+ );
69
+ });
70
+
71
+ // Success cases
72
+ it("creates permission with valid key", async () => {
73
+ const { db, spies } = createMockDb<DB>();
74
+ const createdPermission = {
75
+ ...basePermission,
76
+ id: "new-permission-id",
77
+ key: "users:read",
78
+ description: null,
79
+ };
80
+
81
+ spies.select.mockReturnValue(undefined); // No existing permission
82
+ spies.insert.mockReturnValue(createdPermission);
83
+
84
+ const result = await createPermission(db, { key: "users:read" }, ctx);
85
+
86
+ expect(result.permission.key).toBe("users:read");
87
+ expect(spies.insert).toHaveBeenCalled();
88
+ });
89
+
90
+ it("creates permission with description", async () => {
91
+ const { db, spies } = createMockDb<DB>();
92
+ const createdPermission = {
93
+ ...basePermission,
94
+ id: "new-permission-id",
95
+ key: "users:write",
96
+ description: "Permission to write users",
97
+ };
98
+
99
+ spies.select.mockReturnValue(undefined);
100
+ spies.insert.mockReturnValue(createdPermission);
101
+
102
+ const result = await createPermission(
103
+ db,
104
+ {
105
+ key: "users:write",
106
+ description: "Permission to write users",
107
+ },
108
+ ctx,
109
+ );
110
+
111
+ expect(result.permission.key).toBe("users:write");
112
+ expect(result.permission.description).toBe("Permission to write users");
113
+ });
114
+
115
+ it("throws when permission is missing", async () => {
116
+ const { db } = createMockDb<DB>();
117
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
118
+ await expect(createPermission(db, { key: "users:read" }, denied)).rejects.toBeInstanceOf(
119
+ InsufficientPermissionError,
120
+ );
121
+ });
122
+
123
+ it("passes custom fields through to insert", async () => {
124
+ const createPermissionWithFields = makeCreatePermission<{ module: string }>();
125
+ const { db, spies } = createMockDb<DB>();
126
+ const createdPermission = {
127
+ ...basePermission,
128
+ id: "new-permission-id",
129
+ key: "users:read",
130
+ module: "user-management",
131
+ };
132
+
133
+ spies.select.mockReturnValue(undefined);
134
+ spies.insert.mockReturnValue(createdPermission);
135
+
136
+ await createPermissionWithFields(db, { key: "users:read", module: "user-management" }, ctx);
137
+
138
+ expect(spies.values).toHaveBeenNthCalledWith(
139
+ 1,
140
+ expect.objectContaining({ module: "user-management" }),
141
+ );
142
+ });
143
+ });
@@ -0,0 +1,66 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import {
4
+ DuplicatePermissionKeyError,
5
+ InvalidPermissionKeyFormatError,
6
+ MissingRequiredFieldError,
7
+ } from "../lib/errors";
8
+ import { permissions } from "../permissions";
9
+
10
+ interface CreatePermissionInput {
11
+ key: string;
12
+ description?: string;
13
+ }
14
+
15
+ const PERMISSION_KEY_PATTERN = /^[a-zA-Z0-9_-]+:[a-zA-Z0-9_-]+$/;
16
+
17
+ /**
18
+ * Function: createPermission
19
+ *
20
+ * Creates a new permission with the specified key in resource:action format.
21
+ * Validates that the key follows the correct format and is unique.
22
+ */
23
+ export function makeCreatePermission<CF extends Record<string, unknown>>() {
24
+ return defineCommand(
25
+ permissions.createPermission,
26
+ async (db: DB, input: CreatePermissionInput & CF) => {
27
+ const { key, description, ...customFields } = input;
28
+
29
+ // 1. Validate key is provided
30
+ if (!key || key.trim() === "") {
31
+ throw new MissingRequiredFieldError("key");
32
+ }
33
+
34
+ // 2. Validate key format (resource:action)
35
+ if (!PERMISSION_KEY_PATTERN.test(key)) {
36
+ throw new InvalidPermissionKeyFormatError(key);
37
+ }
38
+
39
+ // 3. Check if permission key already exists
40
+ const existingPermission = await db
41
+ .selectFrom("Permission")
42
+ .selectAll()
43
+ .where("key", "=", key)
44
+ .executeTakeFirst();
45
+
46
+ if (existingPermission) {
47
+ throw new DuplicatePermissionKeyError(key);
48
+ }
49
+
50
+ // 4. Create permission
51
+ const permission = await db
52
+ .insertInto("Permission")
53
+ .values({
54
+ ...(customFields as Record<string, unknown>),
55
+ key,
56
+ description: description ?? null,
57
+ createdAt: new Date(),
58
+ updatedAt: null,
59
+ })
60
+ .returningAll()
61
+ .executeTakeFirst();
62
+
63
+ return { permission: permission! };
64
+ },
65
+ );
66
+ }
@@ -0,0 +1,115 @@
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 { DuplicateRoleNameError, MissingRequiredFieldError } from "../lib/errors";
6
+ import { baseRole } from "../testing/fixtures";
7
+ import { makeCreateRole } from "./createRole";
8
+
9
+ const createRole = makeCreateRole();
10
+
11
+ describe("createRole", () => {
12
+ const ctx: CommandContext = {
13
+ actorId: "test-actor",
14
+ permissions: ["user-management:createRole"],
15
+ };
16
+
17
+ // Error cases
18
+ it("throws when name is empty", async () => {
19
+ const { db } = createMockDb<DB>();
20
+
21
+ await expect(createRole(db, { name: "" }, ctx)).rejects.toBeInstanceOf(
22
+ MissingRequiredFieldError,
23
+ );
24
+ });
25
+
26
+ it("throws when name is whitespace only", async () => {
27
+ const { db } = createMockDb<DB>();
28
+
29
+ await expect(createRole(db, { name: " " }, ctx)).rejects.toBeInstanceOf(
30
+ MissingRequiredFieldError,
31
+ );
32
+ });
33
+
34
+ it("throws when role name already exists", async () => {
35
+ const { db, spies } = createMockDb<DB>();
36
+ spies.select.mockReturnValue(baseRole);
37
+
38
+ await expect(createRole(db, { name: baseRole.name }, ctx)).rejects.toBeInstanceOf(
39
+ DuplicateRoleNameError,
40
+ );
41
+ });
42
+
43
+ // Success cases
44
+ it("creates role with valid name", async () => {
45
+ const { db, spies } = createMockDb<DB>();
46
+ const createdRole = {
47
+ ...baseRole,
48
+ id: "new-role-id",
49
+ name: "Viewer",
50
+ description: null,
51
+ };
52
+
53
+ spies.select.mockReturnValue(undefined); // No existing role
54
+ spies.insert.mockReturnValue(createdRole);
55
+
56
+ const result = await createRole(db, { name: "Viewer" }, ctx);
57
+
58
+ expect(result.role.name).toBe("Viewer");
59
+ expect(spies.insert).toHaveBeenCalled();
60
+ });
61
+
62
+ it("creates role with description", async () => {
63
+ const { db, spies } = createMockDb<DB>();
64
+ const createdRole = {
65
+ ...baseRole,
66
+ id: "new-role-id",
67
+ name: "Editor",
68
+ description: "Can edit content",
69
+ };
70
+
71
+ spies.select.mockReturnValue(undefined);
72
+ spies.insert.mockReturnValue(createdRole);
73
+
74
+ const result = await createRole(
75
+ db,
76
+ {
77
+ name: "Editor",
78
+ description: "Can edit content",
79
+ },
80
+ ctx,
81
+ );
82
+
83
+ expect(result.role.name).toBe("Editor");
84
+ expect(result.role.description).toBe("Can edit content");
85
+ });
86
+
87
+ it("throws when permission is missing", async () => {
88
+ const { db } = createMockDb<DB>();
89
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
90
+ await expect(createRole(db, { name: "Test" }, denied)).rejects.toBeInstanceOf(
91
+ InsufficientPermissionError,
92
+ );
93
+ });
94
+
95
+ it("passes custom fields through to insert", async () => {
96
+ const createRoleWithFields = makeCreateRole<{ department: string }>();
97
+ const { db, spies } = createMockDb<DB>();
98
+ const createdRole = {
99
+ ...baseRole,
100
+ id: "new-role-id",
101
+ name: "Viewer",
102
+ department: "Engineering",
103
+ };
104
+
105
+ spies.select.mockReturnValue(undefined);
106
+ spies.insert.mockReturnValue(createdRole);
107
+
108
+ await createRoleWithFields(db, { name: "Viewer", department: "Engineering" }, ctx);
109
+
110
+ expect(spies.values).toHaveBeenNthCalledWith(
111
+ 1,
112
+ expect.objectContaining({ department: "Engineering" }),
113
+ );
114
+ });
115
+ });
@@ -0,0 +1,52 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { DuplicateRoleNameError, MissingRequiredFieldError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ interface CreateRoleInput {
7
+ name: string;
8
+ description?: string;
9
+ }
10
+
11
+ /**
12
+ * Function: createRole
13
+ *
14
+ * Creates a new role with the specified name.
15
+ * Validates that the name is unique within the system.
16
+ */
17
+ export function makeCreateRole<CF extends Record<string, unknown>>() {
18
+ return defineCommand(permissions.createRole, async (db: DB, input: CreateRoleInput & CF) => {
19
+ const { name, description, ...customFields } = input;
20
+
21
+ // 1. Validate name is provided
22
+ if (!name || name.trim() === "") {
23
+ throw new MissingRequiredFieldError("name");
24
+ }
25
+
26
+ // 2. Check if role name already exists
27
+ const existingRole = await db
28
+ .selectFrom("Role")
29
+ .selectAll()
30
+ .where("name", "=", name)
31
+ .executeTakeFirst();
32
+
33
+ if (existingRole) {
34
+ throw new DuplicateRoleNameError(name);
35
+ }
36
+
37
+ // 3. Create role
38
+ const role = await db
39
+ .insertInto("Role")
40
+ .values({
41
+ ...(customFields as Record<string, unknown>),
42
+ name,
43
+ description: description ?? null,
44
+ createdAt: new Date(),
45
+ updatedAt: null,
46
+ })
47
+ .returningAll()
48
+ .executeTakeFirst();
49
+
50
+ return { role: role! };
51
+ });
52
+ }