@shipfox/api-projects 2.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 (202) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +117 -0
  5. package/LICENSE +21 -0
  6. package/dist/core/entities/index.d.ts +2 -0
  7. package/dist/core/entities/index.d.ts.map +1 -0
  8. package/dist/core/entities/index.js +3 -0
  9. package/dist/core/entities/index.js.map +1 -0
  10. package/dist/core/entities/project.d.ts +10 -0
  11. package/dist/core/entities/project.d.ts.map +1 -0
  12. package/dist/core/entities/project.js +3 -0
  13. package/dist/core/entities/project.js.map +1 -0
  14. package/dist/core/errors.d.ts +13 -0
  15. package/dist/core/errors.d.ts.map +1 -0
  16. package/dist/core/errors.js +20 -0
  17. package/dist/core/errors.js.map +1 -0
  18. package/dist/core/index.d.ts +5 -0
  19. package/dist/core/index.d.ts.map +1 -0
  20. package/dist/core/index.js +4 -0
  21. package/dist/core/index.js.map +1 -0
  22. package/dist/core/projects.d.ts +12 -0
  23. package/dist/core/projects.d.ts.map +1 -0
  24. package/dist/core/projects.js +62 -0
  25. package/dist/core/projects.js.map +1 -0
  26. package/dist/db/db.d.ts +763 -0
  27. package/dist/db/db.d.ts.map +1 -0
  28. package/dist/db/db.js +22 -0
  29. package/dist/db/db.js.map +1 -0
  30. package/dist/db/index.d.ts +8 -0
  31. package/dist/db/index.d.ts.map +1 -0
  32. package/dist/db/index.js +10 -0
  33. package/dist/db/index.js.map +1 -0
  34. package/dist/db/integration-event-dedup.d.ts +17 -0
  35. package/dist/db/integration-event-dedup.d.ts.map +1 -0
  36. package/dist/db/integration-event-dedup.js +27 -0
  37. package/dist/db/integration-event-dedup.js.map +1 -0
  38. package/dist/db/projects.d.ts +36 -0
  39. package/dist/db/projects.d.ts.map +1 -0
  40. package/dist/db/projects.js +84 -0
  41. package/dist/db/projects.js.map +1 -0
  42. package/dist/db/schema/common.d.ts +2 -0
  43. package/dist/db/schema/common.d.ts.map +1 -0
  44. package/dist/db/schema/common.js +4 -0
  45. package/dist/db/schema/common.js.map +1 -0
  46. package/dist/db/schema/integration-event-dedup.d.ts +59 -0
  47. package/dist/db/schema/integration-event-dedup.d.ts.map +1 -0
  48. package/dist/db/schema/integration-event-dedup.js +19 -0
  49. package/dist/db/schema/integration-event-dedup.js.map +1 -0
  50. package/dist/db/schema/outbox.d.ts +195 -0
  51. package/dist/db/schema/outbox.d.ts.map +1 -0
  52. package/dist/db/schema/outbox.js +5 -0
  53. package/dist/db/schema/outbox.js.map +1 -0
  54. package/dist/db/schema/projects.d.ts +131 -0
  55. package/dist/db/schema/projects.d.ts.map +1 -0
  56. package/dist/db/schema/projects.js +32 -0
  57. package/dist/db/schema/projects.js.map +1 -0
  58. package/dist/index.d.ts +12 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +59 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/metrics/index.d.ts +3 -0
  63. package/dist/metrics/index.d.ts.map +1 -0
  64. package/dist/metrics/index.js +4 -0
  65. package/dist/metrics/index.js.map +1 -0
  66. package/dist/metrics/instance.d.ts +4 -0
  67. package/dist/metrics/instance.d.ts.map +1 -0
  68. package/dist/metrics/instance.js +25 -0
  69. package/dist/metrics/instance.js.map +1 -0
  70. package/dist/metrics/service.d.ts +2 -0
  71. package/dist/metrics/service.d.ts.map +1 -0
  72. package/dist/metrics/service.js +15 -0
  73. package/dist/metrics/service.js.map +1 -0
  74. package/dist/presentation/auth/require-project-access.d.ts +12 -0
  75. package/dist/presentation/auth/require-project-access.d.ts.map +1 -0
  76. package/dist/presentation/auth/require-project-access.js +25 -0
  77. package/dist/presentation/auth/require-project-access.js.map +1 -0
  78. package/dist/presentation/dto/index.d.ts +2 -0
  79. package/dist/presentation/dto/index.d.ts.map +1 -0
  80. package/dist/presentation/dto/index.js +3 -0
  81. package/dist/presentation/dto/index.js.map +1 -0
  82. package/dist/presentation/dto/project.d.ts +13 -0
  83. package/dist/presentation/dto/project.d.ts.map +1 -0
  84. package/dist/presentation/dto/project.js +15 -0
  85. package/dist/presentation/dto/project.js.map +1 -0
  86. package/dist/presentation/e2eRoutes/create-project.d.ts +2 -0
  87. package/dist/presentation/e2eRoutes/create-project.d.ts.map +1 -0
  88. package/dist/presentation/e2eRoutes/create-project.js +45 -0
  89. package/dist/presentation/e2eRoutes/create-project.js.map +1 -0
  90. package/dist/presentation/e2eRoutes/index.d.ts +3 -0
  91. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  92. package/dist/presentation/e2eRoutes/index.js +9 -0
  93. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  94. package/dist/presentation/index.d.ts +3 -0
  95. package/dist/presentation/index.d.ts.map +1 -0
  96. package/dist/presentation/index.js +4 -0
  97. package/dist/presentation/index.js.map +1 -0
  98. package/dist/presentation/routes/create-project.d.ts +3 -0
  99. package/dist/presentation/routes/create-project.d.ts.map +1 -0
  100. package/dist/presentation/routes/create-project.js +94 -0
  101. package/dist/presentation/routes/create-project.js.map +1 -0
  102. package/dist/presentation/routes/cursor.d.ts +2 -0
  103. package/dist/presentation/routes/cursor.d.ts.map +1 -0
  104. package/dist/presentation/routes/cursor.js +3 -0
  105. package/dist/presentation/routes/cursor.js.map +1 -0
  106. package/dist/presentation/routes/get-project.d.ts +2 -0
  107. package/dist/presentation/routes/get-project.d.ts.map +1 -0
  108. package/dist/presentation/routes/get-project.js +30 -0
  109. package/dist/presentation/routes/get-project.js.map +1 -0
  110. package/dist/presentation/routes/index.d.ts +4 -0
  111. package/dist/presentation/routes/index.d.ts.map +1 -0
  112. package/dist/presentation/routes/index.js +17 -0
  113. package/dist/presentation/routes/index.js.map +1 -0
  114. package/dist/presentation/routes/list-projects.d.ts +2 -0
  115. package/dist/presentation/routes/list-projects.d.ts.map +1 -0
  116. package/dist/presentation/routes/list-projects.js +43 -0
  117. package/dist/presentation/routes/list-projects.js.map +1 -0
  118. package/dist/presentation/subscribers/index.d.ts +2 -0
  119. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  120. package/dist/presentation/subscribers/index.js +3 -0
  121. package/dist/presentation/subscribers/index.js.map +1 -0
  122. package/dist/presentation/subscribers/on-source-commit-pushed.d.ts +4 -0
  123. package/dist/presentation/subscribers/on-source-commit-pushed.d.ts.map +1 -0
  124. package/dist/presentation/subscribers/on-source-commit-pushed.js +54 -0
  125. package/dist/presentation/subscribers/on-source-commit-pushed.js.map +1 -0
  126. package/dist/temporal/activities/index.d.ts +5 -0
  127. package/dist/temporal/activities/index.d.ts.map +1 -0
  128. package/dist/temporal/activities/index.js +8 -0
  129. package/dist/temporal/activities/index.js.map +1 -0
  130. package/dist/temporal/activities/prune-integration-event-dedup.d.ts +4 -0
  131. package/dist/temporal/activities/prune-integration-event-dedup.d.ts.map +1 -0
  132. package/dist/temporal/activities/prune-integration-event-dedup.js +11 -0
  133. package/dist/temporal/activities/prune-integration-event-dedup.js.map +1 -0
  134. package/dist/temporal/constants.d.ts +3 -0
  135. package/dist/temporal/constants.d.ts.map +1 -0
  136. package/dist/temporal/constants.js +4 -0
  137. package/dist/temporal/constants.js.map +1 -0
  138. package/dist/temporal/workflows/index.d.ts +2 -0
  139. package/dist/temporal/workflows/index.d.ts.map +1 -0
  140. package/dist/temporal/workflows/index.js +3 -0
  141. package/dist/temporal/workflows/index.js.map +1 -0
  142. package/dist/temporal/workflows/prune-integration-event-dedup-cron.d.ts +2 -0
  143. package/dist/temporal/workflows/prune-integration-event-dedup-cron.d.ts.map +1 -0
  144. package/dist/temporal/workflows/prune-integration-event-dedup-cron.js +14 -0
  145. package/dist/temporal/workflows/prune-integration-event-dedup-cron.js.map +1 -0
  146. package/dist/tsconfig.test.tsbuildinfo +1 -0
  147. package/drizzle/0000_initial.sql +37 -0
  148. package/drizzle/meta/0000_snapshot.json +307 -0
  149. package/drizzle/meta/_journal.json +13 -0
  150. package/drizzle.config.ts +7 -0
  151. package/package.json +76 -0
  152. package/src/core/entities/index.ts +1 -0
  153. package/src/core/entities/project.ts +9 -0
  154. package/src/core/errors.ts +26 -0
  155. package/src/core/index.ts +8 -0
  156. package/src/core/projects.test.ts +142 -0
  157. package/src/core/projects.ts +97 -0
  158. package/src/db/db.ts +22 -0
  159. package/src/db/index.ts +26 -0
  160. package/src/db/integration-event-dedup.ts +45 -0
  161. package/src/db/projects.test.ts +22 -0
  162. package/src/db/projects.ts +158 -0
  163. package/src/db/schema/common.ts +3 -0
  164. package/src/db/schema/integration-event-dedup.ts +15 -0
  165. package/src/db/schema/outbox.ts +4 -0
  166. package/src/db/schema/projects.ts +39 -0
  167. package/src/index.ts +71 -0
  168. package/src/metrics/index.ts +2 -0
  169. package/src/metrics/instance.ts +34 -0
  170. package/src/metrics/service.ts +17 -0
  171. package/src/presentation/auth/require-project-access.ts +29 -0
  172. package/src/presentation/dto/index.ts +1 -0
  173. package/src/presentation/dto/project.ts +15 -0
  174. package/src/presentation/e2eRoutes/create-project.test.ts +119 -0
  175. package/src/presentation/e2eRoutes/create-project.ts +50 -0
  176. package/src/presentation/e2eRoutes/index.ts +7 -0
  177. package/src/presentation/index.ts +2 -0
  178. package/src/presentation/routes/create-project.ts +102 -0
  179. package/src/presentation/routes/cursor.ts +5 -0
  180. package/src/presentation/routes/get-project.ts +24 -0
  181. package/src/presentation/routes/index.ts +14 -0
  182. package/src/presentation/routes/list-projects.ts +34 -0
  183. package/src/presentation/routes/projects.test.ts +266 -0
  184. package/src/presentation/subscribers/index.ts +1 -0
  185. package/src/presentation/subscribers/on-source-commit-pushed.test.ts +149 -0
  186. package/src/presentation/subscribers/on-source-commit-pushed.ts +64 -0
  187. package/src/temporal/activities/index.ts +7 -0
  188. package/src/temporal/activities/prune-integration-event-dedup.test.ts +50 -0
  189. package/src/temporal/activities/prune-integration-event-dedup.ts +9 -0
  190. package/src/temporal/constants.ts +3 -0
  191. package/src/temporal/workflows/index.ts +1 -0
  192. package/src/temporal/workflows/prune-integration-event-dedup-cron.ts +15 -0
  193. package/test/env.ts +14 -0
  194. package/test/factories/project.ts +24 -0
  195. package/test/globalSetup.ts +17 -0
  196. package/test/index.ts +1 -0
  197. package/test/setup.ts +17 -0
  198. package/tsconfig.build.json +9 -0
  199. package/tsconfig.build.tsbuildinfo +1 -0
  200. package/tsconfig.json +3 -0
  201. package/tsconfig.test.json +8 -0
  202. package/vitest.config.ts +12 -0
@@ -0,0 +1,119 @@
1
+ import {closeApp, createApp} from '@shipfox/node-fastify';
2
+ import {sql} from 'drizzle-orm';
3
+ import {db} from '#db/index.js';
4
+ import {projectsOutbox} from '#db/schema/outbox.js';
5
+ import {projectsE2eRoutes} from './index.js';
6
+
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
8
+ const E2E_SOURCE_RE = /^e2e:/u;
9
+
10
+ describe('projects E2E routes', () => {
11
+ afterEach(async () => {
12
+ await closeApp();
13
+ });
14
+
15
+ test('creates a project with generated synthetic source values', async () => {
16
+ const workspaceId = crypto.randomUUID();
17
+ const app = await createApp({routes: [projectsE2eRoutes], swagger: false});
18
+
19
+ const res = await app.inject({
20
+ method: 'POST',
21
+ url: '/projects',
22
+ payload: {
23
+ workspace_id: workspaceId,
24
+ name: ' E2E Project ',
25
+ },
26
+ });
27
+
28
+ expect(res.statusCode).toBe(201);
29
+ expect(res.json()).toMatchObject({
30
+ workspace_id: workspaceId,
31
+ name: 'E2E Project',
32
+ });
33
+ expect(res.json().source.connection_id).toMatch(UUID_RE);
34
+ expect(res.json().source.external_repository_id).toMatch(E2E_SOURCE_RE);
35
+ });
36
+
37
+ test('preserves explicit synthetic source values', async () => {
38
+ const workspaceId = crypto.randomUUID();
39
+ const sourceConnectionId = crypto.randomUUID();
40
+ const app = await createApp({routes: [projectsE2eRoutes], swagger: false});
41
+
42
+ const res = await app.inject({
43
+ method: 'POST',
44
+ url: '/projects',
45
+ payload: {
46
+ workspace_id: workspaceId,
47
+ name: 'E2E Project',
48
+ source_connection_id: sourceConnectionId,
49
+ source_external_repository_id: 'e2e:explicit',
50
+ },
51
+ });
52
+
53
+ expect(res.statusCode).toBe(201);
54
+ expect(res.json().source).toEqual({
55
+ connection_id: sourceConnectionId,
56
+ external_repository_id: 'e2e:explicit',
57
+ });
58
+ });
59
+
60
+ test('returns conflict for duplicate explicit source values', async () => {
61
+ const workspaceId = crypto.randomUUID();
62
+ const sourceConnectionId = crypto.randomUUID();
63
+ const app = await createApp({routes: [projectsE2eRoutes], swagger: false});
64
+ const payload = {
65
+ workspace_id: workspaceId,
66
+ name: 'E2E Project',
67
+ source_connection_id: sourceConnectionId,
68
+ source_external_repository_id: 'e2e:duplicate',
69
+ };
70
+ await app.inject({method: 'POST', url: '/projects', payload});
71
+
72
+ const res = await app.inject({method: 'POST', url: '/projects', payload});
73
+
74
+ expect(res.statusCode).toBe(409);
75
+ expect(res.json()).toMatchObject({
76
+ code: 'project-already-exists',
77
+ details: {
78
+ source_connection_id: sourceConnectionId,
79
+ source_external_repository_id: 'e2e:duplicate',
80
+ },
81
+ });
82
+ });
83
+
84
+ test('rejects invalid bodies', async () => {
85
+ const app = await createApp({routes: [projectsE2eRoutes], swagger: false});
86
+
87
+ const res = await app.inject({
88
+ method: 'POST',
89
+ url: '/projects',
90
+ payload: {
91
+ workspace_id: 'not-a-uuid',
92
+ name: 'E2E Project',
93
+ },
94
+ });
95
+
96
+ expect(res.statusCode).toBe(400);
97
+ });
98
+
99
+ test('does not write project outbox events', async () => {
100
+ const workspaceId = crypto.randomUUID();
101
+ const app = await createApp({routes: [projectsE2eRoutes], swagger: false});
102
+
103
+ const res = await app.inject({
104
+ method: 'POST',
105
+ url: '/projects',
106
+ payload: {
107
+ workspace_id: workspaceId,
108
+ name: 'E2E Project',
109
+ },
110
+ });
111
+
112
+ const events = await db()
113
+ .select()
114
+ .from(projectsOutbox)
115
+ .where(sql`${projectsOutbox.payload}->>'projectId' = ${res.json().id}`);
116
+ expect(res.statusCode).toBe(201);
117
+ expect(events).toEqual([]);
118
+ });
119
+ });
@@ -0,0 +1,50 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {
3
+ e2eCreateProjectBodySchema,
4
+ e2eCreateProjectResponseSchema,
5
+ } from '@shipfox/api-projects-dto';
6
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
+ import {ProjectAlreadyExistsError} from '#core/index.js';
8
+ import {createProject} from '#db/index.js';
9
+ import {toProjectDto} from '#presentation/dto/index.js';
10
+
11
+ function syntheticExternalRepositoryId(): string {
12
+ return `e2e:${randomUUID()}`;
13
+ }
14
+
15
+ export const createE2eProjectRoute = defineRoute({
16
+ method: 'POST',
17
+ path: '/',
18
+ description: 'Create a synthetic project for E2E tests.',
19
+ schema: {
20
+ body: e2eCreateProjectBodySchema,
21
+ response: {
22
+ 201: e2eCreateProjectResponseSchema,
23
+ },
24
+ },
25
+ errorHandler: (error) => {
26
+ if (error instanceof ProjectAlreadyExistsError) {
27
+ throw new ClientError(error.message, 'project-already-exists', {
28
+ details: {
29
+ existing_project_id: error.existingProjectId,
30
+ source_connection_id: error.sourceConnectionId,
31
+ source_external_repository_id: error.sourceExternalRepositoryId,
32
+ },
33
+ status: 409,
34
+ });
35
+ }
36
+ throw error;
37
+ },
38
+ handler: async (request, reply) => {
39
+ const project = await createProject({
40
+ workspaceId: request.body.workspace_id,
41
+ name: request.body.name,
42
+ sourceConnectionId: request.body.source_connection_id ?? randomUUID(),
43
+ sourceExternalRepositoryId:
44
+ request.body.source_external_repository_id ?? syntheticExternalRepositoryId(),
45
+ });
46
+
47
+ reply.code(201);
48
+ return toProjectDto(project);
49
+ },
50
+ });
@@ -0,0 +1,7 @@
1
+ import type {RouteGroup} from '@shipfox/node-fastify';
2
+ import {createE2eProjectRoute} from './create-project.js';
3
+
4
+ export const projectsE2eRoutes: RouteGroup = {
5
+ prefix: '/projects',
6
+ routes: [createE2eProjectRoute],
7
+ };
@@ -0,0 +1,2 @@
1
+ export {requireProjectAccess} from './auth/require-project-access.js';
2
+ export {createProjectRoutes} from './routes/index.js';
@@ -0,0 +1,102 @@
1
+ import {AUTH_USER, requireUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import {
3
+ IntegrationCapabilityUnavailableError,
4
+ IntegrationConnectionInactiveError,
5
+ IntegrationConnectionNotFoundError,
6
+ IntegrationConnectionWorkspaceMismatchError,
7
+ IntegrationProviderError,
8
+ type IntegrationProviderErrorReason,
9
+ IntegrationProviderUnavailableError,
10
+ type IntegrationSourceControlService,
11
+ } from '@shipfox/api-integration-core';
12
+ import {createProjectBodySchema, projectResponseSchema} from '@shipfox/api-projects-dto';
13
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
14
+ import {createProjectFromSource, ProjectAlreadyExistsError} from '#core/index.js';
15
+ import {toProjectDto} from '#presentation/dto/index.js';
16
+
17
+ function providerStatus(reason: IntegrationProviderErrorReason): number {
18
+ if (reason === 'rate-limited') return 429;
19
+ if (reason === 'timeout' || reason === 'provider-unavailable') return 503;
20
+ return 422;
21
+ }
22
+
23
+ function isProviderError(error: unknown): error is IntegrationProviderError {
24
+ return (
25
+ error instanceof IntegrationProviderError ||
26
+ (error instanceof Error &&
27
+ 'reason' in error &&
28
+ typeof error.reason === 'string' &&
29
+ (error.reason === 'repository-not-found' ||
30
+ error.reason === 'access-denied' ||
31
+ error.reason === 'rate-limited' ||
32
+ error.reason === 'timeout' ||
33
+ error.reason === 'provider-unavailable' ||
34
+ error.reason === 'malformed-provider-response'))
35
+ );
36
+ }
37
+
38
+ export function createProjectRoute(sourceControl: IntegrationSourceControlService) {
39
+ return defineRoute({
40
+ method: 'POST',
41
+ path: '/',
42
+ auth: AUTH_USER,
43
+ description: 'Create a project bound to a source repository.',
44
+ schema: {
45
+ body: createProjectBodySchema,
46
+ response: {
47
+ 201: projectResponseSchema,
48
+ },
49
+ },
50
+ errorHandler: (error) => {
51
+ if (error instanceof IntegrationConnectionNotFoundError) {
52
+ throw new ClientError(error.message, 'source-connection-not-found', {status: 404});
53
+ }
54
+ if (error instanceof IntegrationConnectionWorkspaceMismatchError) {
55
+ throw new ClientError(error.message, 'forbidden', {status: 403});
56
+ }
57
+ if (error instanceof IntegrationConnectionInactiveError) {
58
+ throw new ClientError(error.message, 'source-connection-inactive', {status: 422});
59
+ }
60
+ if (error instanceof IntegrationProviderUnavailableError) {
61
+ throw new ClientError(error.message, 'integration-provider-unavailable', {status: 422});
62
+ }
63
+ if (error instanceof IntegrationCapabilityUnavailableError) {
64
+ throw new ClientError(error.message, 'integration-capability-unavailable', {status: 422});
65
+ }
66
+ if (error instanceof ProjectAlreadyExistsError) {
67
+ throw new ClientError(error.message, 'project-already-exists', {
68
+ details: {
69
+ existing_project_id: error.existingProjectId,
70
+ source_connection_id: error.sourceConnectionId,
71
+ source_external_repository_id: error.sourceExternalRepositoryId,
72
+ },
73
+ status: 409,
74
+ });
75
+ }
76
+ if (isProviderError(error)) {
77
+ throw new ClientError(error.message, error.reason, {
78
+ details: {retry_after_seconds: error.retryAfterSeconds},
79
+ status: providerStatus(error.reason),
80
+ });
81
+ }
82
+ throw error;
83
+ },
84
+ handler: async (request, reply) => {
85
+ const {workspace_id: workspaceId, name, source} = request.body;
86
+ const actor = requireUserContext(request);
87
+
88
+ requireWorkspaceAccess({request, workspaceId});
89
+ const project = await createProjectFromSource({
90
+ actorId: actor.userId,
91
+ workspaceId,
92
+ name,
93
+ sourceConnectionId: source.connection_id,
94
+ sourceExternalRepositoryId: source.external_repository_id,
95
+ sourceControl,
96
+ });
97
+
98
+ reply.status(201);
99
+ return toProjectDto(project);
100
+ },
101
+ });
102
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ decodeTimestampIdCursor as decodeProjectCursor,
3
+ encodeTimestampIdCursor as encodeProjectCursor,
4
+ type TimestampIdCursor as ProjectCursor,
5
+ } from '@shipfox/node-drizzle';
@@ -0,0 +1,24 @@
1
+ import {AUTH_USER} from '@shipfox/api-auth-context';
2
+ import {projectResponseSchema} from '@shipfox/api-projects-dto';
3
+ import {defineRoute} from '@shipfox/node-fastify';
4
+ import {z} from 'zod';
5
+ import {requireProjectAccess} from '#presentation/auth/require-project-access.js';
6
+ import {toProjectDto} from '#presentation/dto/index.js';
7
+
8
+ export const getProjectRoute = defineRoute({
9
+ method: 'GET',
10
+ path: '/:projectId',
11
+ auth: AUTH_USER,
12
+ description: 'Get a project.',
13
+ schema: {
14
+ params: z.object({projectId: z.string().uuid()}),
15
+ response: {
16
+ 200: projectResponseSchema,
17
+ },
18
+ },
19
+ handler: async (request) => {
20
+ const {projectId} = request.params;
21
+ const {project} = await requireProjectAccess({request, projectId});
22
+ return toProjectDto(project);
23
+ },
24
+ });
@@ -0,0 +1,14 @@
1
+ import type {IntegrationSourceControlService} from '@shipfox/api-integration-core';
2
+ import type {RouteGroup} from '@shipfox/node-fastify';
3
+ import {createProjectRoute} from './create-project.js';
4
+ import {getProjectRoute} from './get-project.js';
5
+ import {listProjectsRoute} from './list-projects.js';
6
+
7
+ export function createProjectRoutes(sourceControl: IntegrationSourceControlService): RouteGroup[] {
8
+ return [
9
+ {
10
+ prefix: '/projects',
11
+ routes: [createProjectRoute(sourceControl), listProjectsRoute, getProjectRoute],
12
+ },
13
+ ];
14
+ }
@@ -0,0 +1,34 @@
1
+ import {AUTH_USER, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import {listProjectsQuerySchema, listProjectsResponseSchema} from '@shipfox/api-projects-dto';
3
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {listProjects} from '#db/index.js';
5
+ import {toProjectDto} from '#presentation/dto/index.js';
6
+ import {decodeProjectCursor, encodeProjectCursor} from './cursor.js';
7
+
8
+ export const listProjectsRoute = defineRoute({
9
+ method: 'GET',
10
+ path: '/',
11
+ auth: AUTH_USER,
12
+ description: 'List projects in a workspace.',
13
+ schema: {
14
+ querystring: listProjectsQuerySchema,
15
+ response: {
16
+ 200: listProjectsResponseSchema,
17
+ },
18
+ },
19
+ handler: async (request) => {
20
+ const {workspace_id: workspaceId, limit, cursor, search} = request.query;
21
+ const decodedCursor = decodeProjectCursor(cursor);
22
+ if (cursor && !decodedCursor) {
23
+ throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
24
+ }
25
+
26
+ requireWorkspaceAccess({request, workspaceId});
27
+ const result = await listProjects({workspaceId, limit, cursor: decodedCursor, search});
28
+
29
+ return {
30
+ projects: result.projects.map(toProjectDto),
31
+ next_cursor: result.nextCursor ? encodeProjectCursor(result.nextCursor) : null,
32
+ };
33
+ },
34
+ });
@@ -0,0 +1,266 @@
1
+ import {
2
+ AUTH_USER,
3
+ buildUserContext,
4
+ setUserContext,
5
+ type UserContextMembership,
6
+ } from '@shipfox/api-auth-context';
7
+ import type {IntegrationSourceControlService} from '@shipfox/api-integration-core';
8
+ import {IntegrationConnectionNotFoundError} from '@shipfox/api-integration-core';
9
+ import type {AuthMethod} from '@shipfox/node-fastify';
10
+ import {closeApp, createApp} from '@shipfox/node-fastify';
11
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
12
+ import {createProjectRoutes} from './index.js';
13
+
14
+ let authenticatedMemberships: UserContextMembership[] = [];
15
+
16
+ const fakeUserAuth: AuthMethod = {
17
+ name: AUTH_USER,
18
+ authenticate: (request: FastifyRequest) => {
19
+ setUserContext(
20
+ request,
21
+ buildUserContext({
22
+ userId: 'user-1',
23
+ email: 'user@example.com',
24
+ name: 'User One',
25
+ memberships: authenticatedMemberships,
26
+ }),
27
+ );
28
+ return Promise.resolve();
29
+ },
30
+ };
31
+
32
+ describe('project routes', () => {
33
+ let app: FastifyInstance;
34
+ let workspaceId: string;
35
+ let sourceConnectionId: string;
36
+ let sourceControl: IntegrationSourceControlService;
37
+
38
+ beforeEach(async () => {
39
+ await closeApp();
40
+ workspaceId = crypto.randomUUID();
41
+ sourceConnectionId = crypto.randomUUID();
42
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
43
+ sourceControl = {
44
+ getConnection: vi.fn(),
45
+ listRepositories: vi.fn(),
46
+ resolveRepository: vi.fn(async () => {
47
+ await Promise.resolve();
48
+ return {
49
+ connection: {
50
+ id: sourceConnectionId,
51
+ workspaceId,
52
+ provider: 'gitea' as const,
53
+ externalAccountId: 'gitea-owner',
54
+ slug: 'gitea_owner',
55
+ displayName: 'Gitea',
56
+ lifecycleStatus: 'active' as const,
57
+ capabilities: ['source_control' as const],
58
+ createdAt: new Date(),
59
+ updatedAt: new Date(),
60
+ },
61
+ repository: {
62
+ externalRepositoryId: 'gitea:gitea-owner/platform',
63
+ owner: 'gitea-owner',
64
+ name: 'platform',
65
+ fullName: 'gitea-owner/platform',
66
+ defaultBranch: 'main',
67
+ visibility: 'private' as const,
68
+ cloneUrl: 'https://gitea.local/gitea-owner/platform.git',
69
+ htmlUrl: 'https://gitea.local/gitea-owner/platform',
70
+ },
71
+ };
72
+ }),
73
+ listFiles: vi.fn(),
74
+ fetchFile: vi.fn(),
75
+ createCheckoutSpec: vi.fn(),
76
+ };
77
+ app = await createApp({
78
+ auth: [fakeUserAuth],
79
+ routes: createProjectRoutes(sourceControl),
80
+ swagger: false,
81
+ });
82
+ await app.ready();
83
+ });
84
+
85
+ afterEach(async () => {
86
+ await closeApp();
87
+ });
88
+
89
+ test('creates a project for a source repository', async () => {
90
+ const res = await app.inject({
91
+ method: 'POST',
92
+ url: '/projects',
93
+ headers: {authorization: 'Bearer user'},
94
+ payload: {
95
+ workspace_id: workspaceId,
96
+ name: ' Platform ',
97
+ source: {
98
+ connection_id: sourceConnectionId,
99
+ external_repository_id: 'gitea:gitea-owner/platform',
100
+ },
101
+ },
102
+ });
103
+
104
+ expect(res.statusCode).toBe(201);
105
+ expect(res.json().name).toBe('Platform');
106
+ expect(res.json().source).toEqual({
107
+ connection_id: sourceConnectionId,
108
+ external_repository_id: 'gitea:gitea-owner/platform',
109
+ });
110
+ });
111
+
112
+ test.each([
113
+ ['blank after trimming', ' '],
114
+ ['with control characters', 'Plat\nform'],
115
+ ['with format characters', 'Plat\u202eform'],
116
+ ])('rejects a project name %s before resolving the repository', async (_case, name) => {
117
+ const res = await app.inject({
118
+ method: 'POST',
119
+ url: '/projects',
120
+ headers: {authorization: 'Bearer user'},
121
+ payload: {
122
+ workspace_id: workspaceId,
123
+ name,
124
+ source: {
125
+ connection_id: sourceConnectionId,
126
+ external_repository_id: 'gitea:gitea-owner/platform',
127
+ },
128
+ },
129
+ });
130
+
131
+ expect(res.statusCode).toBe(400);
132
+ expect(sourceControl.resolveRepository).not.toHaveBeenCalled();
133
+ });
134
+
135
+ test('lists projects for a workspace with source references', async () => {
136
+ const createRes = await app.inject({
137
+ method: 'POST',
138
+ url: '/projects',
139
+ headers: {authorization: 'Bearer user'},
140
+ payload: {
141
+ workspace_id: workspaceId,
142
+ name: 'Platform',
143
+ source: {
144
+ connection_id: sourceConnectionId,
145
+ external_repository_id: 'gitea:gitea-owner/platform',
146
+ },
147
+ },
148
+ });
149
+
150
+ const res = await app.inject({
151
+ method: 'GET',
152
+ url: `/projects?workspace_id=${workspaceId}`,
153
+ headers: {authorization: 'Bearer user'},
154
+ });
155
+
156
+ expect(createRes.statusCode).toBe(201);
157
+ expect(res.statusCode).toBe(200);
158
+ expect(res.json().projects.map((project: {id: string}) => project.id)).toContain(
159
+ createRes.json().id,
160
+ );
161
+ expect(res.json().projects[0].source.connection_id).toBe(sourceConnectionId);
162
+ });
163
+
164
+ test('filters projects by `search` (case-insensitive substring on name)', async () => {
165
+ const names = ['Platform', 'Runner', 'Notifier'];
166
+ for (const [index, name] of names.entries()) {
167
+ vi.mocked(sourceControl.resolveRepository).mockResolvedValueOnce({
168
+ connection: {
169
+ id: sourceConnectionId,
170
+ workspaceId,
171
+ provider: 'gitea',
172
+ externalAccountId: 'gitea-owner',
173
+ slug: 'gitea_owner',
174
+ displayName: 'Gitea',
175
+ lifecycleStatus: 'active',
176
+ createdAt: new Date(),
177
+ updatedAt: new Date(),
178
+ },
179
+ repository: {
180
+ externalRepositoryId: `gitea:gitea-owner/${name.toLowerCase()}-${index}`,
181
+ owner: 'gitea-owner',
182
+ name: name.toLowerCase(),
183
+ fullName: `gitea-owner/${name.toLowerCase()}`,
184
+ defaultBranch: 'main',
185
+ visibility: 'private',
186
+ cloneUrl: `https://gitea.local/gitea-owner/${name.toLowerCase()}.git`,
187
+ htmlUrl: `https://gitea.local/gitea-owner/${name.toLowerCase()}`,
188
+ },
189
+ });
190
+ await app.inject({
191
+ method: 'POST',
192
+ url: '/projects',
193
+ headers: {authorization: 'Bearer user'},
194
+ payload: {
195
+ workspace_id: workspaceId,
196
+ name,
197
+ source: {
198
+ connection_id: sourceConnectionId,
199
+ external_repository_id: `gitea:gitea-owner/${name.toLowerCase()}-${index}`,
200
+ },
201
+ },
202
+ });
203
+ }
204
+
205
+ const res = await app.inject({
206
+ method: 'GET',
207
+ url: `/projects?workspace_id=${workspaceId}&search=runn`,
208
+ headers: {authorization: 'Bearer user'},
209
+ });
210
+
211
+ expect(res.statusCode).toBe(200);
212
+ const returned = res.json().projects.map((project: {name: string}) => project.name);
213
+ expect(returned).toEqual(['Runner']);
214
+ });
215
+
216
+ test('returns 409 when the source repository already has a project', async () => {
217
+ const payload = {
218
+ workspace_id: workspaceId,
219
+ name: 'Platform',
220
+ source: {
221
+ connection_id: sourceConnectionId,
222
+ external_repository_id: 'gitea:gitea-owner/platform',
223
+ },
224
+ };
225
+ await app.inject({
226
+ method: 'POST',
227
+ url: '/projects',
228
+ headers: {authorization: 'Bearer user'},
229
+ payload,
230
+ });
231
+
232
+ const res = await app.inject({
233
+ method: 'POST',
234
+ url: '/projects',
235
+ headers: {authorization: 'Bearer user'},
236
+ payload,
237
+ });
238
+
239
+ expect(res.statusCode).toBe(409);
240
+ expect(res.json().code).toBe('project-already-exists');
241
+ expect(res.json().details.source_connection_id).toBe(sourceConnectionId);
242
+ });
243
+
244
+ test('maps missing source connections to a stable error', async () => {
245
+ vi.mocked(sourceControl.resolveRepository).mockRejectedValueOnce(
246
+ new IntegrationConnectionNotFoundError(sourceConnectionId),
247
+ );
248
+
249
+ const res = await app.inject({
250
+ method: 'POST',
251
+ url: '/projects',
252
+ headers: {authorization: 'Bearer user'},
253
+ payload: {
254
+ workspace_id: workspaceId,
255
+ name: 'Platform',
256
+ source: {
257
+ connection_id: sourceConnectionId,
258
+ external_repository_id: 'gitea:gitea-owner/platform',
259
+ },
260
+ },
261
+ });
262
+
263
+ expect(res.statusCode).toBe(404);
264
+ expect(res.json().code).toBe('source-connection-not-found');
265
+ });
266
+ });
@@ -0,0 +1 @@
1
+ export {onSourceCommitPushed} from './on-source-commit-pushed.js';