@ysk-kit/cli 1.0.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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/dist/add-capability.d.ts +4 -0
  3. package/dist/add-capability.d.ts.map +1 -0
  4. package/dist/add-capability.js +104 -0
  5. package/dist/add-capability.js.map +1 -0
  6. package/dist/add-module.d.ts +5 -0
  7. package/dist/add-module.d.ts.map +1 -0
  8. package/dist/add-module.js +210 -0
  9. package/dist/add-module.js.map +1 -0
  10. package/dist/agent-stubs.d.ts +3 -0
  11. package/dist/agent-stubs.d.ts.map +1 -0
  12. package/dist/agent-stubs.js +25 -0
  13. package/dist/agent-stubs.js.map +1 -0
  14. package/dist/apply-capability.d.ts +3 -0
  15. package/dist/apply-capability.d.ts.map +1 -0
  16. package/dist/apply-capability.js +189 -0
  17. package/dist/apply-capability.js.map +1 -0
  18. package/dist/capability-catalog.d.ts +25 -0
  19. package/dist/capability-catalog.d.ts.map +1 -0
  20. package/dist/capability-catalog.js +123 -0
  21. package/dist/capability-catalog.js.map +1 -0
  22. package/dist/capability-patches.d.ts +40 -0
  23. package/dist/capability-patches.d.ts.map +1 -0
  24. package/dist/capability-patches.js +616 -0
  25. package/dist/capability-patches.js.map +1 -0
  26. package/dist/check-agent.d.ts +9 -0
  27. package/dist/check-agent.d.ts.map +1 -0
  28. package/dist/check-agent.js +85 -0
  29. package/dist/check-agent.js.map +1 -0
  30. package/dist/generate-openapi.d.ts +2 -0
  31. package/dist/generate-openapi.d.ts.map +1 -0
  32. package/dist/generate-openapi.js +52 -0
  33. package/dist/generate-openapi.js.map +1 -0
  34. package/dist/help.d.ts +2 -0
  35. package/dist/help.d.ts.map +1 -0
  36. package/dist/help.js +54 -0
  37. package/dist/help.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +59 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/merge-prisma.d.ts +15 -0
  43. package/dist/merge-prisma.d.ts.map +1 -0
  44. package/dist/merge-prisma.js +90 -0
  45. package/dist/merge-prisma.js.map +1 -0
  46. package/dist/patch-text.d.ts +17 -0
  47. package/dist/patch-text.d.ts.map +1 -0
  48. package/dist/patch-text.js +72 -0
  49. package/dist/patch-text.js.map +1 -0
  50. package/dist/upgrade.d.ts +16 -0
  51. package/dist/upgrade.d.ts.map +1 -0
  52. package/dist/upgrade.js +110 -0
  53. package/dist/upgrade.js.map +1 -0
  54. package/package.json +41 -0
  55. package/templates/agent/skills/add-capability/SKILL.md +8 -0
  56. package/templates/agent/skills/add-module/SKILL.md +8 -0
  57. package/templates/agent/skills/envelope-api/SKILL.md +8 -0
  58. package/templates/agent/skills/fix-layers/SKILL.md +8 -0
  59. package/templates/agent/skills/new-product/SKILL.md +8 -0
  60. package/templates/agent/skills/verify-change/SKILL.md +8 -0
  61. package/templates/agent/ysk-kit.mdc +6 -0
  62. package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.ts +140 -0
  63. package/templates/capabilities/billing/apps/api/src/modules/billing/domain/billing-port.ts +23 -0
  64. package/templates/capabilities/billing/apps/api/src/modules/billing/domain/subscription-repository.ts +22 -0
  65. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing-router.ts +85 -0
  66. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing.app.test.ts +273 -0
  67. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/log-billing.ts +31 -0
  68. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/memory-subscription-repository.ts +27 -0
  69. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/prisma-subscription-repository.ts +52 -0
  70. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.test.ts +127 -0
  71. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.ts +160 -0
  72. package/templates/capabilities/billing/apps/web/src/features/orgs/billing-page.tsx +183 -0
  73. package/templates/capabilities/billing/packages/contracts/src/api/billing.ts +85 -0
  74. package/templates/capabilities/billing/packages/contracts/src/dto/billing.ts +59 -0
  75. package/templates/capabilities/billing/packages/sdk/src/resources/billing.ts +38 -0
  76. package/templates/capabilities/billing/packages/web-sdk/src/billing-hooks.ts +56 -0
  77. package/templates/capabilities/llm/apps/api/src/modules/llm/application/llm-service.ts +48 -0
  78. package/templates/capabilities/llm/apps/api/src/modules/llm/domain/usage-repository.ts +10 -0
  79. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm-router.ts +88 -0
  80. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm.app.test.ts +89 -0
  81. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/memory-usage-repository.ts +13 -0
  82. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/prisma-usage-repository.ts +17 -0
  83. package/templates/capabilities/llm/apps/web/src/features/llm/llm-page.tsx +53 -0
  84. package/templates/capabilities/llm/packages/contracts/src/api/llm.ts +26 -0
  85. package/templates/capabilities/llm/packages/contracts/src/dto/llm.ts +33 -0
  86. package/templates/capabilities/llm/packages/sdk/src/resources/llm.ts +68 -0
  87. package/templates/capabilities/llm/packages/web-sdk/src/llm-hooks.ts +12 -0
  88. package/templates/capabilities/push/apps/api/src/modules/devices/application/device-service.ts +42 -0
  89. package/templates/capabilities/push/apps/api/src/modules/devices/domain/device-repository.ts +22 -0
  90. package/templates/capabilities/push/apps/api/src/modules/devices/infra/device-router.ts +42 -0
  91. package/templates/capabilities/push/apps/api/src/modules/devices/infra/device.app.test.ts +34 -0
  92. package/templates/capabilities/push/apps/api/src/modules/devices/infra/memory-device-repository.ts +33 -0
  93. package/templates/capabilities/push/apps/api/src/modules/devices/infra/prisma-device-repository.ts +42 -0
  94. package/templates/capabilities/push/apps/mobile/src/adapters/push.ts +21 -0
  95. package/templates/capabilities/push/packages/contracts/src/api/devices.ts +33 -0
  96. package/templates/capabilities/push/packages/contracts/src/dto/device.ts +18 -0
  97. package/templates/capabilities/push/packages/sdk/src/resources/devices.ts +10 -0
  98. package/templates/capabilities/team/apps/api/src/modules/organizations/application/organization-service.ts +272 -0
  99. package/templates/capabilities/team/apps/api/src/modules/organizations/domain/organization-repository.ts +58 -0
  100. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/memory-organization-repository.ts +141 -0
  101. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization-router.ts +130 -0
  102. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization.app.test.ts +213 -0
  103. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/prisma-organization-repository.ts +142 -0
  104. package/templates/capabilities/team/apps/mobile/src/screens/invite-screen.tsx +40 -0
  105. package/templates/capabilities/team/apps/mobile/src/screens/org-detail-screen.tsx +154 -0
  106. package/templates/capabilities/team/apps/mobile/src/screens/orgs-screen.tsx +53 -0
  107. package/templates/capabilities/team/apps/web/src/features/orgs/invite-page.tsx +77 -0
  108. package/templates/capabilities/team/apps/web/src/features/orgs/org-detail-page.tsx +138 -0
  109. package/templates/capabilities/team/apps/web/src/features/orgs/orgs-page.tsx +73 -0
  110. package/templates/capabilities/team/packages/contracts/src/api/organizations.ts +154 -0
  111. package/templates/capabilities/team/packages/contracts/src/dto/organization.ts +58 -0
  112. package/templates/capabilities/team/packages/sdk/src/resources/organizations.ts +53 -0
  113. package/templates/capabilities/team/packages/web-sdk/src/organizations-hooks.ts +80 -0
  114. package/templates/module/api.ts.tmpl +32 -0
  115. package/templates/module/application-service.ts.tmpl +9 -0
  116. package/templates/module/domain-repository.ts.tmpl +15 -0
  117. package/templates/module/dto.ts.tmpl +21 -0
  118. package/templates/module/memory-repository.ts.tmpl +36 -0
  119. package/templates/module/model.prisma.tmpl +8 -0
  120. package/templates/module/prisma-repository.ts.tmpl +38 -0
  121. package/templates/module/router.ts.tmpl +32 -0
  122. package/templates/module/sdk-resource.ts.tmpl +17 -0
  123. package/templates/module/service.test.ts.tmpl +33 -0
  124. package/templates/module/web-hooks.ts.tmpl +24 -0
  125. package/templates/module/web-page.tsx.tmpl +69 -0
@@ -0,0 +1,272 @@
1
+ import { hashPassword, hashRefresh, newRefreshToken } from '@ysk-kit/auth';
2
+ import type {
3
+ AcceptInviteCommand,
4
+ CreateOrganizationCommand,
5
+ InviteMemberCommand,
6
+ MembershipDto,
7
+ OrganizationDto,
8
+ OrgInviteDto,
9
+ UpdateOrganizationCommand,
10
+ } from '@ysk-kit/contracts';
11
+ import { orgRoleCan } from '@ysk-kit/contracts';
12
+ import { AppError } from '@ysk-kit/domain-kernel';
13
+ import type { IJobQueue } from '@ysk-kit/jobs';
14
+ import type { IAuditLogger } from '../../audit-log/domain/audit-logger';
15
+ import type { IUserRepository } from '../../identity/domain/user-repository';
16
+ import type { IOrganizationRepository } from '../domain/organization-repository';
17
+
18
+ const INVITE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
19
+
20
+ const toOrgDto = (row: { id: string; name: string; createdAt: Date }): OrganizationDto => ({
21
+ id: row.id,
22
+ name: row.name,
23
+ createdAt: row.createdAt.toISOString(),
24
+ });
25
+
26
+ const toInviteDto = (row: {
27
+ id: string;
28
+ organizationId: string;
29
+ email: string;
30
+ role: OrgInviteDto['role'];
31
+ createdAt: Date;
32
+ expiresAt: Date;
33
+ }): OrgInviteDto => ({
34
+ id: row.id,
35
+ organizationId: row.organizationId,
36
+ email: row.email,
37
+ role: row.role,
38
+ createdAt: row.createdAt.toISOString(),
39
+ expiresAt: row.expiresAt.toISOString(),
40
+ });
41
+
42
+ const displayNameFromEmail = (email: string): string => {
43
+ const local = email.split('@')[0] ?? 'user';
44
+ return local.slice(0, 80) || 'user';
45
+ };
46
+
47
+ export type OrganizationServiceDeps = {
48
+ orgs: IOrganizationRepository;
49
+ users: IUserRepository;
50
+ jobs: IJobQueue;
51
+ audit: IAuditLogger;
52
+ webPublicUrl: string;
53
+ now?: () => Date;
54
+ };
55
+
56
+ export const createOrganizationService = (deps: OrganizationServiceDeps) => {
57
+ const now = () => deps.now?.() ?? new Date();
58
+
59
+ const requireOrg = async (organizationId: string) => {
60
+ const org = await deps.orgs.findById(organizationId);
61
+ if (!org) throw new AppError('NOT_FOUND');
62
+ return org;
63
+ };
64
+
65
+ const requireMember = async (organizationId: string, userId: string) => {
66
+ await requireOrg(organizationId);
67
+ const membership = await deps.orgs.findMembership(organizationId, userId);
68
+ if (!membership) throw new AppError('FORBIDDEN');
69
+ return membership;
70
+ };
71
+
72
+ const hydrateMember = async (row: {
73
+ id: string;
74
+ organizationId: string;
75
+ userId: string;
76
+ role: MembershipDto['role'];
77
+ createdAt: Date;
78
+ }): Promise<MembershipDto> => {
79
+ const user = await deps.users.findById(row.userId);
80
+ return {
81
+ id: row.id,
82
+ organizationId: row.organizationId,
83
+ userId: row.userId,
84
+ email: user?.email ?? null,
85
+ displayName: user?.displayName ?? 'user',
86
+ role: row.role,
87
+ createdAt: row.createdAt.toISOString(),
88
+ };
89
+ };
90
+
91
+ return {
92
+ create: async (actorId: string, body: CreateOrganizationCommand): Promise<OrganizationDto> => {
93
+ const org = await deps.orgs.createWithOwner({ name: body.name, ownerUserId: actorId });
94
+ await deps.audit.record({
95
+ actorId,
96
+ action: 'org.create',
97
+ resourceType: 'organization',
98
+ resourceId: org.id,
99
+ });
100
+ return toOrgDto(org);
101
+ },
102
+
103
+ listMine: (actorId: string) =>
104
+ deps.orgs.listForUser(actorId).then((rows) => rows.map(toOrgDto)),
105
+
106
+ get: async (actorId: string, organizationId: string): Promise<OrganizationDto> => {
107
+ const org = await requireOrg(organizationId);
108
+ await requireMember(organizationId, actorId);
109
+ return toOrgDto(org);
110
+ },
111
+
112
+ update: async (
113
+ actorId: string,
114
+ organizationId: string,
115
+ body: UpdateOrganizationCommand,
116
+ ): Promise<OrganizationDto> => {
117
+ const membership = await requireMember(organizationId, actorId);
118
+ if (!orgRoleCan(membership.role, 'org.update')) throw new AppError('FORBIDDEN');
119
+ const org = await deps.orgs.updateName(organizationId, body.name);
120
+ return toOrgDto(org);
121
+ },
122
+
123
+ members: async (actorId: string, organizationId: string): Promise<MembershipDto[]> => {
124
+ await requireMember(organizationId, actorId);
125
+ const rows = await deps.orgs.listMembers(organizationId);
126
+ return Promise.all(rows.map(hydrateMember));
127
+ },
128
+
129
+ invite: async (
130
+ actorId: string,
131
+ organizationId: string,
132
+ body: InviteMemberCommand,
133
+ ): Promise<OrgInviteDto> => {
134
+ const membership = await requireMember(organizationId, actorId);
135
+ if (!orgRoleCan(membership.role, 'org.invite')) throw new AppError('FORBIDDEN');
136
+ const org = await requireOrg(organizationId);
137
+ const email = body.email.trim().toLowerCase();
138
+ const existingUser = await deps.users.findByEmail(email);
139
+ if (existingUser) {
140
+ if (existingUser.status === 'SUSPENDED' || existingUser.status === 'DELETED') {
141
+ throw new AppError('CONFLICT', 'Account cannot be invited');
142
+ }
143
+ const already = await deps.orgs.findMembership(organizationId, existingUser.id);
144
+ if (already) throw new AppError('CONFLICT', 'Already a member');
145
+ }
146
+ const token = newRefreshToken();
147
+ const invite = await deps.orgs.upsertPendingInvite({
148
+ organizationId,
149
+ email,
150
+ role: body.role,
151
+ tokenHash: hashRefresh(token),
152
+ expiresAt: new Date(now().getTime() + INVITE_TTL_MS),
153
+ invitedById: actorId,
154
+ });
155
+ let user = existingUser;
156
+ if (!user) {
157
+ user = await deps.users.create({
158
+ email,
159
+ displayName: displayNameFromEmail(email),
160
+ role: 'USER',
161
+ status: 'INVITED',
162
+ });
163
+ }
164
+ await deps.jobs.enqueue('email.send', {
165
+ to: email,
166
+ template: 'org.invite',
167
+ locale: 'zh-HK',
168
+ vars: {
169
+ organizationName: org.name,
170
+ inviteUrl: `${deps.webPublicUrl}/invite?token=${token}`,
171
+ },
172
+ });
173
+ await deps.jobs.enqueue('notification.create', {
174
+ userId: user.id,
175
+ type: 'org.invited',
176
+ title: '組織邀請',
177
+ body: `你被邀請加入 ${org.name}。`,
178
+ });
179
+ await deps.audit.record({
180
+ actorId,
181
+ action: 'org.invite',
182
+ resourceType: 'organization',
183
+ resourceId: organizationId,
184
+ });
185
+ return toInviteDto(invite);
186
+ },
187
+
188
+ invites: async (actorId: string, organizationId: string): Promise<OrgInviteDto[]> => {
189
+ const membership = await requireMember(organizationId, actorId);
190
+ if (!orgRoleCan(membership.role, 'org.invite')) throw new AppError('FORBIDDEN');
191
+ const rows = await deps.orgs.listPendingInvites(organizationId);
192
+ return rows.map(toInviteDto);
193
+ },
194
+
195
+ revokeInvite: async (actorId: string, organizationId: string, inviteId: string) => {
196
+ const membership = await requireMember(organizationId, actorId);
197
+ if (!orgRoleCan(membership.role, 'org.invite')) throw new AppError('FORBIDDEN');
198
+ const ok = await deps.orgs.deleteInvite(inviteId, organizationId);
199
+ if (!ok) throw new AppError('NOT_FOUND');
200
+ return { removed: true as const };
201
+ },
202
+
203
+ removeMember: async (actorId: string, organizationId: string, userId: string) => {
204
+ const membership = await requireMember(organizationId, actorId);
205
+ if (!orgRoleCan(membership.role, 'org.member.remove')) throw new AppError('FORBIDDEN');
206
+ const target = await deps.orgs.findMembership(organizationId, userId);
207
+ if (!target) throw new AppError('NOT_FOUND');
208
+ if (target.role === 'OWNER' && membership.role !== 'OWNER') {
209
+ throw new AppError('FORBIDDEN');
210
+ }
211
+ if (target.role === 'OWNER' && (await deps.orgs.countOwners(organizationId)) <= 1) {
212
+ throw new AppError('FORBIDDEN', 'Cannot remove the last owner');
213
+ }
214
+ await deps.orgs.removeMembership(organizationId, userId);
215
+ await deps.audit.record({
216
+ actorId,
217
+ action: 'org.member.remove',
218
+ resourceType: 'user',
219
+ resourceId: userId,
220
+ });
221
+ return { removed: true as const };
222
+ },
223
+
224
+ leave: async (actorId: string, organizationId: string) => {
225
+ const membership = await requireMember(organizationId, actorId);
226
+ if (membership.role === 'OWNER' && (await deps.orgs.countOwners(organizationId)) <= 1) {
227
+ throw new AppError('FORBIDDEN', 'Cannot leave as the last owner');
228
+ }
229
+ await deps.orgs.removeMembership(organizationId, actorId);
230
+ return { left: true as const };
231
+ },
232
+
233
+ acceptInvite: async (body: AcceptInviteCommand) => {
234
+ const invite = await deps.orgs.findInviteByTokenHash(hashRefresh(body.token));
235
+ if (!invite || invite.acceptedAt || invite.expiresAt < now()) {
236
+ throw new AppError('UNAUTHENTICATED', 'Invalid or expired token');
237
+ }
238
+ let user = await deps.users.findByEmail(invite.email);
239
+ if (user && (user.status === 'SUSPENDED' || user.status === 'DELETED')) {
240
+ throw new AppError('CONFLICT', 'Account cannot join');
241
+ }
242
+ if (!user) {
243
+ if (!body.password) throw new AppError('VALIDATION_FAILED', 'Password is required');
244
+ user = await deps.users.create({
245
+ email: invite.email,
246
+ displayName: body.displayName ?? displayNameFromEmail(invite.email),
247
+ role: 'USER',
248
+ status: 'ACTIVE',
249
+ passwordHash: await hashPassword(body.password),
250
+ });
251
+ } else if (user.status === 'INVITED') {
252
+ if (!body.password) throw new AppError('VALIDATION_FAILED', 'Password is required');
253
+ user.passwordHash = await hashPassword(body.password);
254
+ user.status = 'ACTIVE';
255
+ if (body.displayName) user.displayName = body.displayName;
256
+ await deps.users.save(user);
257
+ }
258
+ const already = await deps.orgs.findMembership(invite.organizationId, user.id);
259
+ if (!already) {
260
+ await deps.orgs.addMembership({
261
+ organizationId: invite.organizationId,
262
+ userId: user.id,
263
+ role: invite.role,
264
+ });
265
+ }
266
+ await deps.orgs.markInviteAccepted(invite.id, now());
267
+ return { accepted: true as const };
268
+ },
269
+ };
270
+ };
271
+
272
+ export type OrganizationService = ReturnType<typeof createOrganizationService>;
@@ -0,0 +1,58 @@
1
+ import type { InviteOrgRole, OrgRole } from '@ysk-kit/contracts';
2
+
3
+ export type OrganizationRecord = {
4
+ id: string;
5
+ name: string;
6
+ stripeCustomerId: string | null;
7
+ createdAt: Date;
8
+ };
9
+
10
+ export type MembershipRecord = {
11
+ id: string;
12
+ organizationId: string;
13
+ userId: string;
14
+ role: OrgRole;
15
+ createdAt: Date;
16
+ };
17
+
18
+ export type OrgInviteRecord = {
19
+ id: string;
20
+ organizationId: string;
21
+ email: string;
22
+ role: InviteOrgRole;
23
+ tokenHash: string;
24
+ expiresAt: Date;
25
+ acceptedAt: Date | null;
26
+ invitedById: string;
27
+ createdAt: Date;
28
+ };
29
+
30
+ export interface IOrganizationRepository {
31
+ createWithOwner(input: { name: string; ownerUserId: string }): Promise<OrganizationRecord>;
32
+ findById(id: string): Promise<OrganizationRecord | null>;
33
+ listForUser(userId: string): Promise<OrganizationRecord[]>;
34
+ updateName(id: string, name: string): Promise<OrganizationRecord>;
35
+ setStripeCustomerId(id: string, customerId: string): Promise<OrganizationRecord>;
36
+ findMembership(organizationId: string, userId: string): Promise<MembershipRecord | null>;
37
+ listMembers(organizationId: string): Promise<MembershipRecord[]>;
38
+ addMembership(input: {
39
+ organizationId: string;
40
+ userId: string;
41
+ role: OrgRole;
42
+ }): Promise<MembershipRecord>;
43
+ removeMembership(organizationId: string, userId: string): Promise<boolean>;
44
+ countOwners(organizationId: string): Promise<number>;
45
+ upsertPendingInvite(input: {
46
+ organizationId: string;
47
+ email: string;
48
+ role: InviteOrgRole;
49
+ tokenHash: string;
50
+ expiresAt: Date;
51
+ invitedById: string;
52
+ }): Promise<OrgInviteRecord>;
53
+ findPendingInviteByEmail(organizationId: string, email: string): Promise<OrgInviteRecord | null>;
54
+ findInviteByTokenHash(tokenHash: string): Promise<OrgInviteRecord | null>;
55
+ listPendingInvites(organizationId: string): Promise<OrgInviteRecord[]>;
56
+ markInviteAccepted(id: string, at: Date): Promise<void>;
57
+ deleteInvite(id: string, organizationId: string): Promise<boolean>;
58
+ }
@@ -0,0 +1,141 @@
1
+ import type {
2
+ IOrganizationRepository,
3
+ MembershipRecord,
4
+ OrganizationRecord,
5
+ OrgInviteRecord,
6
+ } from '../domain/organization-repository';
7
+
8
+ export const createMemoryOrganizationRepository = (): IOrganizationRepository => {
9
+ const orgs: OrganizationRecord[] = [];
10
+ const members: MembershipRecord[] = [];
11
+ const invites: OrgInviteRecord[] = [];
12
+
13
+ return {
14
+ async createWithOwner(input) {
15
+ const org: OrganizationRecord = {
16
+ id: crypto.randomUUID(),
17
+ name: input.name,
18
+ stripeCustomerId: null,
19
+ createdAt: new Date(),
20
+ };
21
+ orgs.unshift(org);
22
+ members.push({
23
+ id: crypto.randomUUID(),
24
+ organizationId: org.id,
25
+ userId: input.ownerUserId,
26
+ role: 'OWNER',
27
+ createdAt: new Date(),
28
+ });
29
+ return org;
30
+ },
31
+ async findById(id) {
32
+ return orgs.find((row) => row.id === id) ?? null;
33
+ },
34
+ async listForUser(userId) {
35
+ const orgIds = new Set(
36
+ members.filter((row) => row.userId === userId).map((row) => row.organizationId),
37
+ );
38
+ return orgs.filter((org) => orgIds.has(org.id));
39
+ },
40
+ async updateName(id, name) {
41
+ const org = orgs.find((row) => row.id === id);
42
+ if (!org) throw new Error(`organization ${id} missing`);
43
+ org.name = name;
44
+ return org;
45
+ },
46
+ async setStripeCustomerId(id, customerId) {
47
+ const org = orgs.find((row) => row.id === id);
48
+ if (!org) throw new Error(`organization ${id} missing`);
49
+ org.stripeCustomerId = customerId;
50
+ return org;
51
+ },
52
+ async findMembership(organizationId, userId) {
53
+ return (
54
+ members.find((row) => row.organizationId === organizationId && row.userId === userId) ??
55
+ null
56
+ );
57
+ },
58
+ async listMembers(organizationId) {
59
+ return members.filter((row) => row.organizationId === organizationId);
60
+ },
61
+ async addMembership(input) {
62
+ const existing = members.find(
63
+ (row) => row.organizationId === input.organizationId && row.userId === input.userId,
64
+ );
65
+ if (existing) return existing;
66
+ const row: MembershipRecord = { id: crypto.randomUUID(), createdAt: new Date(), ...input };
67
+ members.push(row);
68
+ return row;
69
+ },
70
+ async removeMembership(organizationId, userId) {
71
+ const index = members.findIndex(
72
+ (row) => row.organizationId === organizationId && row.userId === userId,
73
+ );
74
+ if (index < 0) return false;
75
+ members.splice(index, 1);
76
+ return true;
77
+ },
78
+ async countOwners(organizationId) {
79
+ return members.filter((row) => row.organizationId === organizationId && row.role === 'OWNER')
80
+ .length;
81
+ },
82
+ async upsertPendingInvite(input) {
83
+ const email = input.email.toLowerCase();
84
+ const existing = invites.find(
85
+ (row) =>
86
+ row.organizationId === input.organizationId &&
87
+ row.email === email &&
88
+ row.acceptedAt === null,
89
+ );
90
+ if (existing) {
91
+ existing.role = input.role;
92
+ existing.tokenHash = input.tokenHash;
93
+ existing.expiresAt = input.expiresAt;
94
+ existing.invitedById = input.invitedById;
95
+ return existing;
96
+ }
97
+ const row: OrgInviteRecord = {
98
+ id: crypto.randomUUID(),
99
+ createdAt: new Date(),
100
+ acceptedAt: null,
101
+ ...input,
102
+ email,
103
+ };
104
+ invites.push(row);
105
+ return row;
106
+ },
107
+ async findPendingInviteByEmail(organizationId, email) {
108
+ const normalized = email.toLowerCase();
109
+ return (
110
+ invites.find(
111
+ (row) =>
112
+ row.organizationId === organizationId &&
113
+ row.email === normalized &&
114
+ row.acceptedAt === null,
115
+ ) ?? null
116
+ );
117
+ },
118
+ async findInviteByTokenHash(tokenHash) {
119
+ return invites.find((row) => row.tokenHash === tokenHash) ?? null;
120
+ },
121
+ async listPendingInvites(organizationId) {
122
+ return invites.filter(
123
+ (row) => row.organizationId === organizationId && row.acceptedAt === null,
124
+ );
125
+ },
126
+ async markInviteAccepted(id, at) {
127
+ const row = invites.find((item) => item.id === id);
128
+ if (row) row.acceptedAt = at;
129
+ },
130
+ async deleteInvite(id, organizationId) {
131
+ const index = invites.findIndex(
132
+ (row) => row.id === id && row.organizationId === organizationId && row.acceptedAt === null,
133
+ );
134
+ if (index < 0) return false;
135
+ invites.splice(index, 1);
136
+ return true;
137
+ },
138
+ };
139
+ };
140
+
141
+ export type MemoryOrganizationRepository = ReturnType<typeof createMemoryOrganizationRepository>;
@@ -0,0 +1,130 @@
1
+ import { type HttpHandler, mountContract } from '@ysk-kit/api-express';
2
+ import type { AccessClaims } from '@ysk-kit/auth';
3
+ import {
4
+ type AcceptInviteCommand,
5
+ appContract,
6
+ type CreateOrganizationCommand,
7
+ type InviteMemberCommand,
8
+ type UpdateOrganizationCommand,
9
+ } from '@ysk-kit/contracts';
10
+ import { AppError } from '@ysk-kit/domain-kernel';
11
+ import type { Express } from 'express';
12
+ import type { OrganizationService } from '../application/organization-service';
13
+
14
+ const orgId = (params: unknown): string => (params as { organizationId: string }).organizationId;
15
+
16
+ const actor = (auth: AccessClaims | undefined): AccessClaims => {
17
+ if (!auth) throw new AppError('UNAUTHENTICATED');
18
+ return auth;
19
+ };
20
+
21
+ export const organizationHandlers = (
22
+ service: OrganizationService,
23
+ ): Record<string, HttpHandler> => ({
24
+ create: {
25
+ auth: 'required',
26
+ handle: async ({ body, auth }) => ({
27
+ status: 201,
28
+ body: {
29
+ ok: true,
30
+ data: await service.create(actor(auth).sub, body as CreateOrganizationCommand),
31
+ },
32
+ }),
33
+ },
34
+ list: {
35
+ auth: 'required',
36
+ handle: async ({ auth }) => ({
37
+ status: 200,
38
+ body: { ok: true, data: await service.listMine(actor(auth).sub) },
39
+ }),
40
+ },
41
+ acceptInvite: {
42
+ handle: async ({ body }) => ({
43
+ status: 200,
44
+ body: { ok: true, data: await service.acceptInvite(body as AcceptInviteCommand) },
45
+ }),
46
+ },
47
+ get: {
48
+ auth: 'required',
49
+ handle: async ({ params, auth }) => ({
50
+ status: 200,
51
+ body: { ok: true, data: await service.get(actor(auth).sub, orgId(params)) },
52
+ }),
53
+ },
54
+ update: {
55
+ auth: 'required',
56
+ handle: async ({ params, body, auth }) => ({
57
+ status: 200,
58
+ body: {
59
+ ok: true,
60
+ data: await service.update(
61
+ actor(auth).sub,
62
+ orgId(params),
63
+ body as UpdateOrganizationCommand,
64
+ ),
65
+ },
66
+ }),
67
+ },
68
+ members: {
69
+ auth: 'required',
70
+ handle: async ({ params, auth }) => ({
71
+ status: 200,
72
+ body: { ok: true, data: await service.members(actor(auth).sub, orgId(params)) },
73
+ }),
74
+ },
75
+ invite: {
76
+ auth: 'required',
77
+ handle: async ({ params, body, auth }) => ({
78
+ status: 200,
79
+ body: {
80
+ ok: true,
81
+ data: await service.invite(actor(auth).sub, orgId(params), body as InviteMemberCommand),
82
+ },
83
+ }),
84
+ },
85
+ invites: {
86
+ auth: 'required',
87
+ handle: async ({ params, auth }) => ({
88
+ status: 200,
89
+ body: { ok: true, data: await service.invites(actor(auth).sub, orgId(params)) },
90
+ }),
91
+ },
92
+ revokeInvite: {
93
+ auth: 'required',
94
+ handle: async ({ params, auth }) => ({
95
+ status: 200,
96
+ body: {
97
+ ok: true,
98
+ data: await service.revokeInvite(
99
+ actor(auth).sub,
100
+ orgId(params),
101
+ (params as { id: string }).id,
102
+ ),
103
+ },
104
+ }),
105
+ },
106
+ removeMember: {
107
+ auth: 'required',
108
+ handle: async ({ params, auth }) => {
109
+ const p = params as { organizationId: string; userId: string };
110
+ return {
111
+ status: 200,
112
+ body: {
113
+ ok: true,
114
+ data: await service.removeMember(actor(auth).sub, p.organizationId, p.userId),
115
+ },
116
+ };
117
+ },
118
+ },
119
+ leave: {
120
+ auth: 'required',
121
+ handle: async ({ params, auth }) => ({
122
+ status: 200,
123
+ body: { ok: true, data: await service.leave(actor(auth).sub, orgId(params)) },
124
+ }),
125
+ },
126
+ });
127
+
128
+ export const registerOrganizationRoutes = (app: Express, service: OrganizationService): void => {
129
+ mountContract(app, appContract.organizations, organizationHandlers(service));
130
+ };