@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,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 { InvalidStatusTransitionError, UserNotFoundError } from "../lib/errors";
6
+ import { activeUser, baseAuditEvent, inactiveUser, pendingUser } from "../testing/fixtures";
7
+ import { reactivateUser } from "./reactivateUser";
8
+
9
+ describe("reactivateUser", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["user-management:reactivateUser"],
13
+ };
14
+
15
+ // Error cases
16
+ it("throws when user does not exist", async () => {
17
+ const { db, spies } = createMockDb<DB>();
18
+ spies.select.mockReturnValue(undefined);
19
+
20
+ await expect(
21
+ reactivateUser(db, { userId: "nonexistent-user", actorId: "actor-1" }, ctx),
22
+ ).rejects.toBeInstanceOf(UserNotFoundError);
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
+ reactivateUser(db, { userId: pendingUser.id, actorId: "actor-1" }, ctx),
31
+ ).rejects.toBeInstanceOf(InvalidStatusTransitionError);
32
+ });
33
+
34
+ it("throws when user is already ACTIVE", async () => {
35
+ const { db, spies } = createMockDb<DB>();
36
+ spies.select.mockReturnValue(activeUser);
37
+
38
+ await expect(
39
+ reactivateUser(db, { userId: activeUser.id, actorId: "actor-1" }, ctx),
40
+ ).rejects.toBeInstanceOf(InvalidStatusTransitionError);
41
+ });
42
+
43
+ // Success cases
44
+ it("reactivates INACTIVE user to ACTIVE", async () => {
45
+ const { db, spies } = createMockDb<DB>();
46
+ const reactivatedUser = {
47
+ ...inactiveUser,
48
+ status: "ACTIVE" as const,
49
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
50
+ };
51
+ const createdAuditEvent = {
52
+ ...baseAuditEvent,
53
+ eventType: "USER_REACTIVATED" as const,
54
+ actorId: "actor-1",
55
+ targetId: inactiveUser.id,
56
+ };
57
+
58
+ spies.select.mockReturnValue(inactiveUser);
59
+ spies.update.mockReturnValue(reactivatedUser);
60
+ spies.insert.mockReturnValue(createdAuditEvent);
61
+
62
+ const result = await reactivateUser(
63
+ db,
64
+ {
65
+ userId: inactiveUser.id,
66
+ actorId: "actor-1",
67
+ },
68
+ ctx,
69
+ );
70
+
71
+ expect(result.user.status).toBe("ACTIVE");
72
+ expect(result.user.updatedAt).not.toBeNull();
73
+ expect(spies.update).toHaveBeenCalled();
74
+ });
75
+
76
+ it("logs USER_REACTIVATED audit event", async () => {
77
+ const { db, spies } = createMockDb<DB>();
78
+ const reactivatedUser = {
79
+ ...inactiveUser,
80
+ status: "ACTIVE" as const,
81
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
82
+ };
83
+ const createdAuditEvent = {
84
+ ...baseAuditEvent,
85
+ eventType: "USER_REACTIVATED" as const,
86
+ actorId: "actor-1",
87
+ targetId: inactiveUser.id,
88
+ };
89
+
90
+ spies.select.mockReturnValue(inactiveUser);
91
+ spies.update.mockReturnValue(reactivatedUser);
92
+ spies.insert.mockReturnValue(createdAuditEvent);
93
+
94
+ const result = await reactivateUser(
95
+ db,
96
+ {
97
+ userId: inactiveUser.id,
98
+ actorId: "actor-1",
99
+ },
100
+ ctx,
101
+ );
102
+
103
+ expect(result.auditEvent.eventType).toBe("USER_REACTIVATED");
104
+ expect(result.auditEvent.actorId).toBe("actor-1");
105
+ expect(result.auditEvent.targetId).toBe(inactiveUser.id);
106
+ });
107
+
108
+ it("throws when permission is missing", async () => {
109
+ const { db } = createMockDb<DB>();
110
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
111
+ await expect(
112
+ reactivateUser(db, { userId: "some-user", actorId: "actor-1" }, denied),
113
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
114
+ });
115
+ });
@@ -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 ReactivateUserInput {
7
+ userId: string;
8
+ actorId: string;
9
+ from?: string[];
10
+ }
11
+
12
+ /**
13
+ * Function: reactivateUser
14
+ *
15
+ * Transitions a user from INACTIVE back to ACTIVE status.
16
+ * Restores access for previously deactivated users.
17
+ */
18
+ export const reactivateUser = defineCommand(
19
+ permissions.reactivateUser,
20
+ async (db: DB, input: ReactivateUserInput) => {
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 ?? ["INACTIVE"];
35
+ if (!validFromStatuses.includes(user.status)) {
36
+ throw new InvalidStatusTransitionError(user.status, "ACTIVE");
37
+ }
38
+
39
+ // 4. Update status to ACTIVE
40
+ const updatedUser = await db
41
+ .updateTable("User")
42
+ .set({
43
+ status: "ACTIVE",
44
+ updatedAt: new Date(),
45
+ })
46
+ .where("id", "=", input.userId)
47
+ .returningAll()
48
+ .executeTakeFirst();
49
+
50
+ // 5. Log USER_REACTIVATED audit event
51
+ const auditEvent = await db
52
+ .insertInto("AuditEvent")
53
+ .values({
54
+ eventType: "USER_REACTIVATED",
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,112 @@
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 { AssignmentNotFoundError, PermissionNotFoundError, RoleNotFoundError } from "../lib/errors";
6
+ import { baseAuditEvent, basePermission, baseRole, baseRolePermission } from "../testing/fixtures";
7
+ import { revokePermissionFromRole } from "./revokePermissionFromRole";
8
+
9
+ describe("revokePermissionFromRole", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["user-management:revokePermissionFromRole"],
13
+ };
14
+
15
+ it("throws when role does not exist", async () => {
16
+ const { db, spies } = createMockDb<DB>();
17
+ spies.select.mockReturnValue(undefined);
18
+
19
+ await expect(
20
+ revokePermissionFromRole(
21
+ db,
22
+ {
23
+ roleId: "nonexistent-role",
24
+ permissionId: basePermission.id,
25
+ actorId: "actor-1",
26
+ },
27
+ ctx,
28
+ ),
29
+ ).rejects.toBeInstanceOf(RoleNotFoundError);
30
+ });
31
+
32
+ it("throws when permission does not exist", async () => {
33
+ const { db, spies } = createMockDb<DB>();
34
+ spies.select.mockReturnValueOnce(baseRole).mockReturnValueOnce(undefined);
35
+
36
+ await expect(
37
+ revokePermissionFromRole(
38
+ db,
39
+ {
40
+ roleId: baseRole.id,
41
+ permissionId: "nonexistent-permission",
42
+ actorId: "actor-1",
43
+ },
44
+ ctx,
45
+ ),
46
+ ).rejects.toBeInstanceOf(PermissionNotFoundError);
47
+ });
48
+
49
+ it("throws when assignment does not exist", async () => {
50
+ const { db, spies } = createMockDb<DB>();
51
+ spies.select
52
+ .mockReturnValueOnce(baseRole)
53
+ .mockReturnValueOnce(basePermission)
54
+ .mockReturnValueOnce(undefined);
55
+
56
+ await expect(
57
+ revokePermissionFromRole(
58
+ db,
59
+ {
60
+ roleId: baseRole.id,
61
+ permissionId: basePermission.id,
62
+ actorId: "actor-1",
63
+ },
64
+ ctx,
65
+ ),
66
+ ).rejects.toBeInstanceOf(AssignmentNotFoundError);
67
+ });
68
+
69
+ it("deletes RolePermission and logs PERMISSION_REVOKED event", async () => {
70
+ const { db, spies } = createMockDb<DB>();
71
+ const createdAuditEvent = {
72
+ ...baseAuditEvent,
73
+ eventType: "PERMISSION_REVOKED" as const,
74
+ actorId: "actor-1",
75
+ targetId: baseRole.id,
76
+ targetType: "Role",
77
+ };
78
+
79
+ spies.select
80
+ .mockReturnValueOnce(baseRole)
81
+ .mockReturnValueOnce(basePermission)
82
+ .mockReturnValueOnce(baseRolePermission);
83
+ spies.delete.mockReturnValue(baseRolePermission);
84
+ spies.insert.mockReturnValue(createdAuditEvent);
85
+
86
+ const result = await revokePermissionFromRole(
87
+ db,
88
+ {
89
+ roleId: baseRole.id,
90
+ permissionId: basePermission.id,
91
+ actorId: "actor-1",
92
+ },
93
+ ctx,
94
+ );
95
+
96
+ expect(result.auditEvent.eventType).toBe("PERMISSION_REVOKED");
97
+ expect(result.auditEvent.targetId).toBe(baseRole.id);
98
+ expect(spies.delete).toHaveBeenCalled();
99
+ });
100
+
101
+ it("throws when permission is missing", async () => {
102
+ const { db } = createMockDb<DB>();
103
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
104
+ await expect(
105
+ revokePermissionFromRole(
106
+ db,
107
+ { roleId: "role-1", permissionId: "perm-1", actorId: "actor-1" },
108
+ denied,
109
+ ),
110
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
111
+ });
112
+ });
@@ -0,0 +1,81 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { AssignmentNotFoundError, PermissionNotFoundError, RoleNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface RevokePermissionFromRoleInput {
7
+ roleId: string;
8
+ permissionId: string;
9
+ actorId: string;
10
+ }
11
+
12
+ /**
13
+ * Function: revokePermissionFromRole
14
+ *
15
+ * Removes a permission from a role.
16
+ * Throws an error if the assignment does not exist.
17
+ *
18
+ * Note: Permissions for affected users are recomputed asynchronously via executor.
19
+ */
20
+ export const revokePermissionFromRole = defineCommand(
21
+ permissions.revokePermissionFromRole,
22
+ async (db: DB, input: RevokePermissionFromRoleInput) => {
23
+ const role = await db
24
+ .selectFrom("Role")
25
+ .selectAll()
26
+ .where("id", "=", input.roleId)
27
+ .executeTakeFirst();
28
+
29
+ if (!role) {
30
+ throw new RoleNotFoundError(input.roleId);
31
+ }
32
+
33
+ const permission = await db
34
+ .selectFrom("Permission")
35
+ .selectAll()
36
+ .where("id", "=", input.permissionId)
37
+ .executeTakeFirst();
38
+
39
+ if (!permission) {
40
+ throw new PermissionNotFoundError(input.permissionId);
41
+ }
42
+
43
+ const existingAssignment = await db
44
+ .selectFrom("RolePermission")
45
+ .selectAll()
46
+ .where("roleId", "=", input.roleId)
47
+ .where("permissionId", "=", input.permissionId)
48
+ .executeTakeFirst();
49
+
50
+ if (!existingAssignment) {
51
+ throw new AssignmentNotFoundError("Role", input.roleId, "Permission", input.permissionId);
52
+ }
53
+
54
+ // Permission recomputation for affected users is handled asynchronously by executor
55
+ await db
56
+ .deleteFrom("RolePermission")
57
+ .where("id", "=", existingAssignment.id)
58
+ .returningAll()
59
+ .executeTakeFirst();
60
+
61
+ const auditEvent = await db
62
+ .insertInto("AuditEvent")
63
+ .values({
64
+ eventType: "PERMISSION_REVOKED",
65
+ actorId: input.actorId,
66
+ targetId: input.roleId,
67
+ targetType: "Role",
68
+ payload: JSON.stringify({
69
+ roleId: input.roleId,
70
+ permissionId: input.permissionId,
71
+ permissionKey: permission.key,
72
+ }),
73
+ createdAt: new Date(),
74
+ updatedAt: null,
75
+ })
76
+ .returningAll()
77
+ .executeTakeFirst();
78
+
79
+ return { auditEvent: auditEvent! };
80
+ },
81
+ );
@@ -0,0 +1,112 @@
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 { AssignmentNotFoundError, RoleNotFoundError, UserNotFoundError } from "../lib/errors";
6
+ import { activeUser, baseAuditEvent, baseRole, baseUserRole } from "../testing/fixtures";
7
+ import { revokeRoleFromUser } from "./revokeRoleFromUser";
8
+
9
+ describe("revokeRoleFromUser", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["user-management:revokeRoleFromUser"],
13
+ };
14
+
15
+ it("throws when user does not exist", async () => {
16
+ const { db, spies } = createMockDb<DB>();
17
+ spies.select.mockReturnValue(undefined);
18
+
19
+ await expect(
20
+ revokeRoleFromUser(
21
+ db,
22
+ {
23
+ userId: "nonexistent-user",
24
+ roleId: baseRole.id,
25
+ actorId: "actor-1",
26
+ },
27
+ ctx,
28
+ ),
29
+ ).rejects.toBeInstanceOf(UserNotFoundError);
30
+ });
31
+
32
+ it("throws when role does not exist", async () => {
33
+ const { db, spies } = createMockDb<DB>();
34
+ spies.select.mockReturnValueOnce(activeUser).mockReturnValueOnce(undefined);
35
+
36
+ await expect(
37
+ revokeRoleFromUser(
38
+ db,
39
+ {
40
+ userId: activeUser.id,
41
+ roleId: "nonexistent-role",
42
+ actorId: "actor-1",
43
+ },
44
+ ctx,
45
+ ),
46
+ ).rejects.toBeInstanceOf(RoleNotFoundError);
47
+ });
48
+
49
+ it("throws when assignment does not exist", async () => {
50
+ const { db, spies } = createMockDb<DB>();
51
+ spies.select
52
+ .mockReturnValueOnce(activeUser)
53
+ .mockReturnValueOnce(baseRole)
54
+ .mockReturnValueOnce(undefined);
55
+
56
+ await expect(
57
+ revokeRoleFromUser(
58
+ db,
59
+ {
60
+ userId: activeUser.id,
61
+ roleId: baseRole.id,
62
+ actorId: "actor-1",
63
+ },
64
+ ctx,
65
+ ),
66
+ ).rejects.toBeInstanceOf(AssignmentNotFoundError);
67
+ });
68
+
69
+ it("deletes UserRole and logs ROLE_REVOKED event", async () => {
70
+ const { db, spies } = createMockDb<DB>();
71
+ const createdAuditEvent = {
72
+ ...baseAuditEvent,
73
+ eventType: "ROLE_REVOKED" as const,
74
+ actorId: "actor-1",
75
+ targetId: activeUser.id,
76
+ targetType: "User",
77
+ };
78
+ const updatedUser = { ...activeUser, permissions: [] };
79
+
80
+ spies.select
81
+ .mockReturnValueOnce(activeUser)
82
+ .mockReturnValueOnce(baseRole)
83
+ .mockReturnValueOnce(baseUserRole)
84
+ .mockReturnValueOnce([]); // Permission keys from recompute (empty after revoke)
85
+ spies.delete.mockReturnValue(baseUserRole);
86
+ spies.insert.mockReturnValue(createdAuditEvent);
87
+ spies.update.mockReturnValue(updatedUser);
88
+
89
+ const result = await revokeRoleFromUser(
90
+ db,
91
+ {
92
+ userId: activeUser.id,
93
+ roleId: baseRole.id,
94
+ actorId: "actor-1",
95
+ },
96
+ ctx,
97
+ );
98
+
99
+ expect(result.auditEvent.eventType).toBe("ROLE_REVOKED");
100
+ expect(result.auditEvent.targetId).toBe(activeUser.id);
101
+ expect(result.user.permissions).toEqual([]);
102
+ expect(spies.delete).toHaveBeenCalled();
103
+ });
104
+
105
+ it("throws when permission is missing", async () => {
106
+ const { db } = createMockDb<DB>();
107
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
108
+ await expect(
109
+ revokeRoleFromUser(db, { userId: "user-1", roleId: "role-1", actorId: "actor-1" }, denied),
110
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
111
+ });
112
+ });
@@ -0,0 +1,81 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { AssignmentNotFoundError, RoleNotFoundError, UserNotFoundError } from "../lib/errors";
4
+ import { recomputeUserPermissions } from "../lib/recomputeUserPermissions";
5
+ import { permissions } from "../permissions";
6
+
7
+ export interface RevokeRoleFromUserInput {
8
+ userId: string;
9
+ roleId: string;
10
+ actorId: string;
11
+ }
12
+
13
+ /**
14
+ * Function: revokeRoleFromUser
15
+ *
16
+ * Removes a role from a user.
17
+ * Throws an error if the assignment does not exist.
18
+ */
19
+ export const revokeRoleFromUser = defineCommand(
20
+ permissions.revokeRoleFromUser,
21
+ async (db: DB, input: RevokeRoleFromUserInput) => {
22
+ const user = await db
23
+ .selectFrom("User")
24
+ .selectAll()
25
+ .where("id", "=", input.userId)
26
+ .executeTakeFirst();
27
+
28
+ if (!user) {
29
+ throw new UserNotFoundError(input.userId);
30
+ }
31
+
32
+ const role = await db
33
+ .selectFrom("Role")
34
+ .selectAll()
35
+ .where("id", "=", input.roleId)
36
+ .executeTakeFirst();
37
+
38
+ if (!role) {
39
+ throw new RoleNotFoundError(input.roleId);
40
+ }
41
+
42
+ const existingAssignment = await db
43
+ .selectFrom("UserRole")
44
+ .selectAll()
45
+ .where("userId", "=", input.userId)
46
+ .where("roleId", "=", input.roleId)
47
+ .executeTakeFirst();
48
+
49
+ if (!existingAssignment) {
50
+ throw new AssignmentNotFoundError("User", input.userId, "Role", input.roleId);
51
+ }
52
+
53
+ await db
54
+ .deleteFrom("UserRole")
55
+ .where("id", "=", existingAssignment.id)
56
+ .returningAll()
57
+ .executeTakeFirst();
58
+
59
+ const auditEvent = await db
60
+ .insertInto("AuditEvent")
61
+ .values({
62
+ eventType: "ROLE_REVOKED",
63
+ actorId: input.actorId,
64
+ targetId: input.userId,
65
+ targetType: "User",
66
+ payload: JSON.stringify({
67
+ userId: input.userId,
68
+ roleId: input.roleId,
69
+ roleName: role.name,
70
+ }),
71
+ createdAt: new Date(),
72
+ updatedAt: null,
73
+ })
74
+ .returningAll()
75
+ .executeTakeFirst();
76
+
77
+ const updatedUser = await recomputeUserPermissions(db, input.userId);
78
+
79
+ return { user: updatedUser, auditEvent: auditEvent! };
80
+ },
81
+ );
@@ -0,0 +1,47 @@
1
+ import {
2
+ db,
3
+ unsafeAllowAllGqlPermission,
4
+ unsafeAllowAllTypePermission,
5
+ } from "@tailor-platform/sdk";
6
+
7
+ export interface CreateAuditEventTypeParams {
8
+ fields?: Record<string, unknown>;
9
+ additionalEventTypes?: string[];
10
+ }
11
+
12
+ const BASE_EVENT_TYPES = [
13
+ "USER_CREATED",
14
+ "USER_ACTIVATED",
15
+ "USER_DEACTIVATED",
16
+ "USER_REACTIVATED",
17
+ "ROLE_ASSIGNED",
18
+ "ROLE_REVOKED",
19
+ "PERMISSION_ASSIGNED",
20
+ "PERMISSION_REVOKED",
21
+ ] as const;
22
+
23
+ export function createAuditEventType(params: CreateAuditEventTypeParams) {
24
+ const eventTypes = [...BASE_EVENT_TYPES, ...(params.additionalEventTypes ?? [])] as [
25
+ string,
26
+ ...string[],
27
+ ];
28
+
29
+ return db
30
+ .type("AuditEvent", {
31
+ eventType: db.enum(eventTypes).description("Type of audit event"),
32
+ actorId: db.uuid().description("ID of the user who performed the action"),
33
+ targetId: db.uuid({ optional: true }).description("ID of the affected entity"),
34
+ targetType: db
35
+ .string({ optional: true })
36
+ .description("Type of the affected entity (User, Role, Permission)"),
37
+ payload: db
38
+ .string({ optional: true })
39
+ .description("JSON payload with additional event details"),
40
+ ...params.fields,
41
+ ...db.fields.timestamps(),
42
+ })
43
+ .permission(unsafeAllowAllTypePermission)
44
+ .gqlPermission(unsafeAllowAllGqlPermission);
45
+ }
46
+
47
+ export const auditEvent = createAuditEventType({});
@@ -0,0 +1,31 @@
1
+ import {
2
+ db,
3
+ type TailorAnyDBField,
4
+ unsafeAllowAllGqlPermission,
5
+ unsafeAllowAllTypePermission,
6
+ } from "@tailor-platform/sdk";
7
+
8
+ export interface CreatePermissionTypeParams<F extends Record<string, TailorAnyDBField>> {
9
+ fields?: F;
10
+ }
11
+
12
+ export function createPermissionType<const F extends Record<string, TailorAnyDBField>>(
13
+ params: CreatePermissionTypeParams<F>,
14
+ ) {
15
+ return db
16
+ .type("Permission", {
17
+ key: db
18
+ .string()
19
+ .unique()
20
+ .description("Permission key in resource:action format (e.g., orders:read)"),
21
+ description: db
22
+ .string({ optional: true })
23
+ .description("Optional description of what this permission grants"),
24
+ ...((params.fields ?? {}) as F),
25
+ ...db.fields.timestamps(),
26
+ })
27
+ .permission(unsafeAllowAllTypePermission)
28
+ .gqlPermission(unsafeAllowAllGqlPermission);
29
+ }
30
+
31
+ export const permission = createPermissionType({});
@@ -0,0 +1,28 @@
1
+ import {
2
+ db,
3
+ type TailorAnyDBField,
4
+ unsafeAllowAllGqlPermission,
5
+ unsafeAllowAllTypePermission,
6
+ } from "@tailor-platform/sdk";
7
+
8
+ export interface CreateRoleTypeParams<F extends Record<string, TailorAnyDBField>> {
9
+ fields?: F;
10
+ }
11
+
12
+ export function createRoleType<const F extends Record<string, TailorAnyDBField>>(
13
+ params: CreateRoleTypeParams<F>,
14
+ ) {
15
+ return db
16
+ .type("Role", {
17
+ name: db.string().unique().description("Role name (unique identifier)"),
18
+ description: db
19
+ .string({ optional: true })
20
+ .description("Optional description of what this role represents"),
21
+ ...((params.fields ?? {}) as F),
22
+ ...db.fields.timestamps(),
23
+ })
24
+ .permission(unsafeAllowAllTypePermission)
25
+ .gqlPermission(unsafeAllowAllGqlPermission);
26
+ }
27
+
28
+ export const role = createRoleType({});