@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,120 @@
1
+ import { vi, expect } from "vitest";
2
+ import type { Kysely } from "kysely";
3
+ import { InsufficientPermissionError, type CommandContext, type Command } from "../shared/internal";
4
+
5
+ export type Spy = ReturnType<typeof vi.fn>;
6
+
7
+ export interface QueryBuilder {
8
+ where(...args: unknown[]): QueryBuilder;
9
+ selectAll(...args: unknown[]): QueryBuilder;
10
+ select(...args: unknown[]): QueryBuilder;
11
+ innerJoin(...args: unknown[]): QueryBuilder;
12
+ values(...args: unknown[]): QueryBuilder;
13
+ returningAll(...args: unknown[]): QueryBuilder;
14
+ set(...args: unknown[]): QueryBuilder;
15
+ orderBy(...args: unknown[]): QueryBuilder;
16
+ executeTakeFirst(): unknown;
17
+ executeTakeFirstOrThrow(): unknown;
18
+ execute(): unknown;
19
+ }
20
+
21
+ export interface MockDbSpies {
22
+ select: Spy;
23
+ update: Spy;
24
+ insert: Spy;
25
+ delete: Spy;
26
+ values: Spy;
27
+ set: Spy;
28
+ }
29
+
30
+ function makeBuilder(executeSpy: Spy, valuesSpy: Spy, setSpy: Spy): QueryBuilder {
31
+ const builder: QueryBuilder = {} as QueryBuilder;
32
+ builder.where = () => builder;
33
+ builder.selectAll = () => builder;
34
+ builder.select = () => builder;
35
+ builder.innerJoin = () => builder;
36
+ builder.values = (...args: unknown[]) => {
37
+ valuesSpy(...args);
38
+ return builder;
39
+ };
40
+ builder.returningAll = () => builder;
41
+ builder.set = (...args: unknown[]) => {
42
+ setSpy(...args);
43
+ return builder;
44
+ };
45
+ builder.orderBy = () => builder;
46
+ builder.executeTakeFirst = () => executeSpy();
47
+ builder.executeTakeFirstOrThrow = () => executeSpy();
48
+ builder.execute = () => executeSpy();
49
+ return builder;
50
+ }
51
+
52
+ export function createMockDb<T = Kysely<Record<string, unknown>>>(): {
53
+ db: T;
54
+ spies: MockDbSpies;
55
+ reset: () => void;
56
+ } {
57
+ const select = vi.fn();
58
+ const update = vi.fn();
59
+ const insert = vi.fn();
60
+ const del = vi.fn();
61
+ const values = vi.fn();
62
+ const set = vi.fn();
63
+
64
+ const db = {
65
+ selectFrom: () => makeBuilder(select, values, set),
66
+ updateTable: () => makeBuilder(update, values, set),
67
+ insertInto: () => makeBuilder(insert, values, set),
68
+ deleteFrom: () => makeBuilder(del, values, set),
69
+ } as unknown as T;
70
+
71
+ return {
72
+ db,
73
+ spies: { select, update, insert, delete: del, values, set },
74
+ reset: () => {
75
+ select.mockReset();
76
+ update.mockReset();
77
+ insert.mockReset();
78
+ del.mockReset();
79
+ values.mockReset();
80
+ set.mockReset();
81
+ },
82
+ };
83
+ }
84
+
85
+ export function testNotFound<TInput>(
86
+ command: Command<TInput, unknown>,
87
+ input: TInput,
88
+ ctx: CommandContext,
89
+ ErrorClass: abstract new (...args: never[]) => Error,
90
+ ) {
91
+ return async () => {
92
+ const { db, spies } = createMockDb();
93
+ spies.select.mockReturnValue(undefined);
94
+ await expect(command(db, input, ctx)).rejects.toBeInstanceOf(ErrorClass);
95
+ };
96
+ }
97
+
98
+ export function testPermissionDenied<TInput>(command: Command<TInput, unknown>, input: TInput) {
99
+ return async () => {
100
+ const { db } = createMockDb();
101
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
102
+ await expect(command(db, input, denied)).rejects.toBeInstanceOf(InsufficientPermissionError);
103
+ };
104
+ }
105
+
106
+ export function testIdempotent<TInput>(
107
+ command: Command<TInput, Record<string, unknown>>,
108
+ input: TInput,
109
+ ctx: CommandContext,
110
+ existingEntity: Record<string, unknown>,
111
+ entityKey: string,
112
+ ) {
113
+ return async () => {
114
+ const { db, spies } = createMockDb();
115
+ spies.select.mockReturnValue(existingEntity);
116
+ const result = await command(db, input, ctx);
117
+ expect(result[entityKey]).toEqual(existingEntity);
118
+ expect(spies.update).not.toHaveBeenCalled();
119
+ };
120
+ }
@@ -0,0 +1,38 @@
1
+ # README
2
+
3
+ ## Overview
4
+
5
+ The User Management module provides identity and access control capabilities for the ERP system. It manages user accounts through their lifecycle (creation, activation, deactivation) and implements Role-Based Access Control (RBAC) for authorization. All security-relevant changes are logged to an immutable audit trail.
6
+
7
+ This module serves as a foundational component that other modules depend on for user identity and authorization checks.
8
+
9
+ ## Key Features
10
+
11
+ - **User Account Lifecycle**: Create, activate, deactivate, and reactivate user accounts with clear status transitions (PENDING, ACTIVE, INACTIVE)
12
+ - **Email Uniqueness**: Enforce unique email addresses across all user accounts
13
+ - **Role-Based Access Control**: Define roles that bundle permissions, and assign roles to users for scalable authorization
14
+ - **Permission Management**: Define granular permissions using a `resource:action` format (e.g., `orders:read`, `inventory:write`)
15
+ - **Audit Trail**: Capture immutable records of all security-relevant events for compliance and investigation
16
+
17
+ ## Module Scope
18
+
19
+ ### In Scope
20
+
21
+ - User CRUD operations with status management (PENDING, ACTIVE, INACTIVE)
22
+ - Role definitions and role-to-user assignments
23
+ - Permission definitions and permission-to-role assignments
24
+ - Audit logging of user lifecycle and authorization changes
25
+ - Status-based access restrictions (e.g., only active users can be assigned roles)
26
+
27
+ ### Out of Scope
28
+
29
+ - External identity provider integration (SAML, OIDC, OAuth)
30
+ - Multi-factor authentication (MFA)
31
+ - Single sign-on (SSO) federation
32
+ - Fine-grained attribute-based access control (ABAC)
33
+ - Password storage and authentication mechanisms
34
+ - Session management
35
+
36
+ ## Module Dependencies
37
+
38
+ - None (this is a foundational module)
@@ -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 { activateUser } from "./activateUser";
8
+
9
+ describe("activateUser", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["user-management:activateUser"],
13
+ };
14
+
15
+ it(
16
+ "throws when user does not exist",
17
+ testNotFound(
18
+ activateUser,
19
+ { userId: "nonexistent-user", actorId: "actor-1" },
20
+ ctx,
21
+ UserNotFoundError,
22
+ ),
23
+ );
24
+
25
+ it("throws when user is already ACTIVE", async () => {
26
+ const { db, spies } = createMockDb<DB>();
27
+ spies.select.mockReturnValue(activeUser);
28
+
29
+ await expect(
30
+ activateUser(db, { userId: activeUser.id, actorId: "actor-1" }, ctx),
31
+ ).rejects.toBeInstanceOf(InvalidStatusTransitionError);
32
+ });
33
+
34
+ it("throws when user is INACTIVE (use reactivateUser instead)", async () => {
35
+ const { db, spies } = createMockDb<DB>();
36
+ spies.select.mockReturnValue(inactiveUser);
37
+
38
+ await expect(
39
+ activateUser(db, { userId: inactiveUser.id, actorId: "actor-1" }, ctx),
40
+ ).rejects.toBeInstanceOf(InvalidStatusTransitionError);
41
+ });
42
+
43
+ // Success cases
44
+ it("activates PENDING user to ACTIVE", async () => {
45
+ const { db, spies } = createMockDb<DB>();
46
+ const activatedUser = {
47
+ ...pendingUser,
48
+ status: "ACTIVE" as const,
49
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
50
+ };
51
+ const createdAuditEvent = {
52
+ ...baseAuditEvent,
53
+ eventType: "USER_ACTIVATED" as const,
54
+ actorId: "actor-1",
55
+ targetId: pendingUser.id,
56
+ };
57
+
58
+ spies.select.mockReturnValue(pendingUser);
59
+ spies.update.mockReturnValue(activatedUser);
60
+ spies.insert.mockReturnValue(createdAuditEvent);
61
+
62
+ const result = await activateUser(
63
+ db,
64
+ {
65
+ userId: pendingUser.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_ACTIVATED audit event", async () => {
77
+ const { db, spies } = createMockDb<DB>();
78
+ const activatedUser = {
79
+ ...pendingUser,
80
+ status: "ACTIVE" as const,
81
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
82
+ };
83
+ const createdAuditEvent = {
84
+ ...baseAuditEvent,
85
+ eventType: "USER_ACTIVATED" as const,
86
+ actorId: "actor-1",
87
+ targetId: pendingUser.id,
88
+ };
89
+
90
+ spies.select.mockReturnValue(pendingUser);
91
+ spies.update.mockReturnValue(activatedUser);
92
+ spies.insert.mockReturnValue(createdAuditEvent);
93
+
94
+ const result = await activateUser(
95
+ db,
96
+ {
97
+ userId: pendingUser.id,
98
+ actorId: "actor-1",
99
+ },
100
+ ctx,
101
+ );
102
+
103
+ expect(result.auditEvent.eventType).toBe("USER_ACTIVATED");
104
+ expect(result.auditEvent.actorId).toBe("actor-1");
105
+ expect(result.auditEvent.targetId).toBe(pendingUser.id);
106
+ });
107
+
108
+ it(
109
+ "throws when permission is missing",
110
+ testPermissionDenied(activateUser, { 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 ActivateUserInput {
7
+ userId: string;
8
+ actorId: string;
9
+ from?: string[];
10
+ }
11
+
12
+ /**
13
+ * Function: activateUser
14
+ *
15
+ * Transitions a user from PENDING to ACTIVE status.
16
+ * Only PENDING users can be activated; use reactivateUser for INACTIVE users.
17
+ */
18
+ export const activateUser = defineCommand(
19
+ permissions.activateUser,
20
+ async (db: DB, input: ActivateUserInput) => {
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 ?? ["PENDING"];
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_ACTIVATED audit event
51
+ const auditEvent = await db
52
+ .insertInto("AuditEvent")
53
+ .values({
54
+ eventType: "USER_ACTIVATED",
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,119 @@
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 { PermissionNotFoundError, RoleNotFoundError } from "../lib/errors";
6
+ import { baseAuditEvent, basePermission, baseRole, baseRolePermission } from "../testing/fixtures";
7
+ import { assignPermissionToRole } from "./assignPermissionToRole";
8
+
9
+ describe("assignPermissionToRole", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["user-management:assignPermissionToRole"],
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
+ assignPermissionToRole(
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
35
+ .mockReturnValueOnce(baseRole) // Role found
36
+ .mockReturnValueOnce(undefined); // Permission not found
37
+
38
+ await expect(
39
+ assignPermissionToRole(
40
+ db,
41
+ {
42
+ roleId: baseRole.id,
43
+ permissionId: "nonexistent-permission",
44
+ actorId: "actor-1",
45
+ },
46
+ ctx,
47
+ ),
48
+ ).rejects.toBeInstanceOf(PermissionNotFoundError);
49
+ });
50
+
51
+ it("returns success when assignment already exists (idempotent)", async () => {
52
+ const { db, spies } = createMockDb<DB>();
53
+ spies.select
54
+ .mockReturnValueOnce(baseRole) // Role found
55
+ .mockReturnValueOnce(basePermission) // Permission found
56
+ .mockReturnValueOnce(baseRolePermission); // Assignment already exists
57
+
58
+ const result = await assignPermissionToRole(
59
+ db,
60
+ {
61
+ roleId: baseRole.id,
62
+ permissionId: basePermission.id,
63
+ actorId: "actor-1",
64
+ },
65
+ ctx,
66
+ );
67
+
68
+ expect(result.rolePermission).toEqual(baseRolePermission);
69
+ expect(spies.insert).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("creates RolePermission and logs PERMISSION_ASSIGNED event", async () => {
73
+ const { db, spies } = createMockDb<DB>();
74
+ const newRolePermission = {
75
+ ...baseRolePermission,
76
+ id: "new-role-permission-id",
77
+ };
78
+ const createdAuditEvent = {
79
+ ...baseAuditEvent,
80
+ eventType: "PERMISSION_ASSIGNED" as const,
81
+ actorId: "actor-1",
82
+ targetId: baseRole.id,
83
+ targetType: "Role",
84
+ };
85
+
86
+ spies.select
87
+ .mockReturnValueOnce(baseRole) // Role found
88
+ .mockReturnValueOnce(basePermission) // Permission found
89
+ .mockReturnValueOnce(undefined); // No existing assignment
90
+ spies.insert.mockReturnValueOnce(newRolePermission).mockReturnValueOnce(createdAuditEvent);
91
+
92
+ const result = await assignPermissionToRole(
93
+ db,
94
+ {
95
+ roleId: baseRole.id,
96
+ permissionId: basePermission.id,
97
+ actorId: "actor-1",
98
+ },
99
+ ctx,
100
+ );
101
+
102
+ expect(result.rolePermission.roleId).toBe(baseRole.id);
103
+ expect(result.rolePermission.permissionId).toBe(basePermission.id);
104
+ expect(result.auditEvent?.eventType).toBe("PERMISSION_ASSIGNED");
105
+ expect(spies.insert).toHaveBeenCalled();
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
+ assignPermissionToRole(
113
+ db,
114
+ { roleId: "role-1", permissionId: "perm-1", actorId: "actor-1" },
115
+ denied,
116
+ ),
117
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
118
+ });
119
+ });
@@ -0,0 +1,87 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { PermissionNotFoundError, RoleNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface AssignPermissionToRoleInput {
7
+ roleId: string;
8
+ permissionId: string;
9
+ actorId: string;
10
+ }
11
+
12
+ /**
13
+ * Function: assignPermissionToRole
14
+ *
15
+ * Assigns a permission to a role. Operation is idempotent - assigning
16
+ * the same permission twice does not create duplicate records or error.
17
+ *
18
+ * Note: Permissions for affected users are recomputed asynchronously via executor.
19
+ */
20
+ export const assignPermissionToRole = defineCommand(
21
+ permissions.assignPermissionToRole,
22
+ async (db: DB, input: AssignPermissionToRoleInput) => {
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
+ // Idempotent: return existing assignment without creating duplicates
44
+ const existingAssignment = await db
45
+ .selectFrom("RolePermission")
46
+ .selectAll()
47
+ .where("roleId", "=", input.roleId)
48
+ .where("permissionId", "=", input.permissionId)
49
+ .executeTakeFirst();
50
+
51
+ if (existingAssignment) {
52
+ return { rolePermission: existingAssignment };
53
+ }
54
+
55
+ // Permission recomputation for affected users is handled asynchronously by executor
56
+ const rolePermission = await db
57
+ .insertInto("RolePermission")
58
+ .values({
59
+ roleId: input.roleId,
60
+ permissionId: input.permissionId,
61
+ createdAt: new Date(),
62
+ updatedAt: null,
63
+ })
64
+ .returningAll()
65
+ .executeTakeFirst();
66
+
67
+ const auditEvent = await db
68
+ .insertInto("AuditEvent")
69
+ .values({
70
+ eventType: "PERMISSION_ASSIGNED",
71
+ actorId: input.actorId,
72
+ targetId: input.roleId,
73
+ targetType: "Role",
74
+ payload: JSON.stringify({
75
+ roleId: input.roleId,
76
+ permissionId: input.permissionId,
77
+ permissionKey: permission.key,
78
+ }),
79
+ createdAt: new Date(),
80
+ updatedAt: null,
81
+ })
82
+ .returningAll()
83
+ .executeTakeFirst();
84
+
85
+ return { rolePermission: rolePermission!, auditEvent: auditEvent! };
86
+ },
87
+ );