@shipfox/api-projects 11.0.0 → 12.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 (96) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +83 -0
  4. package/dist/core/entities/project.d.ts +4 -0
  5. package/dist/core/entities/project.d.ts.map +1 -1
  6. package/dist/core/entities/project.js.map +1 -1
  7. package/dist/core/errors.d.ts +4 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +3 -3
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +2 -2
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/projects.d.ts +8 -0
  16. package/dist/core/projects.d.ts.map +1 -1
  17. package/dist/core/projects.js +62 -19
  18. package/dist/core/projects.js.map +1 -1
  19. package/dist/db/db.d.ts +138 -0
  20. package/dist/db/db.d.ts.map +1 -1
  21. package/dist/db/db.js.map +1 -1
  22. package/dist/db/index.d.ts +2 -2
  23. package/dist/db/index.d.ts.map +1 -1
  24. package/dist/db/index.js +1 -1
  25. package/dist/db/index.js.map +1 -1
  26. package/dist/db/projects.d.ts +53 -0
  27. package/dist/db/projects.d.ts.map +1 -1
  28. package/dist/db/projects.js +116 -18
  29. package/dist/db/projects.js.map +1 -1
  30. package/dist/db/schema/projects.d.ts +68 -0
  31. package/dist/db/schema/projects.d.ts.map +1 -1
  32. package/dist/db/schema/projects.js +12 -1
  33. package/dist/db/schema/projects.js.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +8 -5
  37. package/dist/index.js.map +1 -1
  38. package/dist/presentation/dto/project.d.ts +1 -0
  39. package/dist/presentation/dto/project.d.ts.map +1 -1
  40. package/dist/presentation/dto/project.js +1 -0
  41. package/dist/presentation/dto/project.js.map +1 -1
  42. package/dist/presentation/e2eRoutes/create-project.d.ts.map +1 -1
  43. package/dist/presentation/e2eRoutes/create-project.js +34 -8
  44. package/dist/presentation/e2eRoutes/create-project.js.map +1 -1
  45. package/dist/presentation/inter-module.d.ts +4 -1
  46. package/dist/presentation/inter-module.d.ts.map +1 -1
  47. package/dist/presentation/inter-module.js +44 -3
  48. package/dist/presentation/inter-module.js.map +1 -1
  49. package/dist/presentation/routes/create-project.d.ts.map +1 -1
  50. package/dist/presentation/routes/create-project.js +8 -2
  51. package/dist/presentation/routes/create-project.js.map +1 -1
  52. package/dist/presentation/routes/index.d.ts.map +1 -1
  53. package/dist/presentation/routes/index.js +3 -1
  54. package/dist/presentation/routes/index.js.map +1 -1
  55. package/dist/presentation/routes/update-project.d.ts +2 -0
  56. package/dist/presentation/routes/update-project.d.ts.map +1 -0
  57. package/dist/presentation/routes/update-project.js +53 -0
  58. package/dist/presentation/routes/update-project.js.map +1 -0
  59. package/dist/presentation/subscribers/index.d.ts +1 -0
  60. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  61. package/dist/presentation/subscribers/index.js +1 -0
  62. package/dist/presentation/subscribers/index.js.map +1 -1
  63. package/dist/presentation/subscribers/on-source-repository-updated.d.ts +4 -0
  64. package/dist/presentation/subscribers/on-source-repository-updated.d.ts.map +1 -0
  65. package/dist/presentation/subscribers/on-source-repository-updated.js +39 -0
  66. package/dist/presentation/subscribers/on-source-repository-updated.js.map +1 -0
  67. package/dist/tsconfig.test.tsbuildinfo +1 -1
  68. package/drizzle/0000_initial.sql +6 -0
  69. package/drizzle/meta/0000_snapshot.json +78 -0
  70. package/package.json +13 -12
  71. package/src/core/entities/project.ts +4 -0
  72. package/src/core/errors.ts +7 -0
  73. package/src/core/index.ts +3 -2
  74. package/src/core/projects.test.ts +10 -0
  75. package/src/core/projects.ts +90 -35
  76. package/src/db/db.ts +3 -0
  77. package/src/db/index.ts +6 -0
  78. package/src/db/projects.test.ts +44 -1
  79. package/src/db/projects.ts +242 -33
  80. package/src/db/schema/projects.ts +16 -0
  81. package/src/index.ts +9 -3
  82. package/src/presentation/dto/project.ts +1 -0
  83. package/src/presentation/e2eRoutes/create-project.test.ts +23 -0
  84. package/src/presentation/e2eRoutes/create-project.ts +39 -10
  85. package/src/presentation/inter-module.test.ts +309 -0
  86. package/src/presentation/inter-module.ts +76 -4
  87. package/src/presentation/routes/create-project.ts +10 -2
  88. package/src/presentation/routes/index.ts +7 -1
  89. package/src/presentation/routes/projects.test.ts +300 -4
  90. package/src/presentation/routes/update-project.ts +46 -0
  91. package/src/presentation/subscribers/index.ts +1 -0
  92. package/src/presentation/subscribers/on-source-commit-pushed.test.ts +2 -0
  93. package/src/presentation/subscribers/on-source-repository-updated.test.ts +108 -0
  94. package/src/presentation/subscribers/on-source-repository-updated.ts +50 -0
  95. package/test/factories/project.ts +6 -0
  96. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,15 +1,35 @@
1
- import {and, count, desc, eq, ilike, inArray, lt, or, type SQL} from 'drizzle-orm';
1
+ import {isUniqueViolation} from '@shipfox/node-drizzle';
2
+ import {and, count, desc, eq, ilike, inArray, lt, or, type SQL, sql} from 'drizzle-orm';
2
3
  import type {Project} from '#core/entities/project.js';
3
- import {ProjectAlreadyExistsError, ProjectNotFoundError} from '#core/errors.js';
4
+ import {
5
+ ProjectAlreadyExistsError,
6
+ ProjectNotFoundError,
7
+ ProjectSlugConflictError,
8
+ } from '#core/errors.js';
4
9
  import {recordProjectCreated} from '#metrics/instance.js';
5
- import {db} from './db.js';
10
+ import {db, type Executor} from './db.js';
6
11
  import {projects, toProject} from './schema/projects.js';
7
12
 
13
+ type ProjectDatabase = ReturnType<typeof db>;
14
+ type ProjectTransaction = Parameters<Parameters<ProjectDatabase['transaction']>[0]>[0];
15
+
8
16
  export interface CreateProjectParams {
9
17
  workspaceId: string;
10
18
  sourceConnectionId: string;
11
19
  sourceExternalRepositoryId: string;
12
20
  name: string;
21
+ slug: string;
22
+ }
23
+
24
+ export interface UpdateProjectParams {
25
+ projectId: string;
26
+ name?: string | undefined;
27
+ slug?: string | undefined;
28
+ }
29
+
30
+ export interface UpdateProjectResult {
31
+ project: Project;
32
+ changed: boolean;
13
33
  }
14
34
 
15
35
  export interface ProjectCursor {
@@ -50,6 +70,8 @@ export interface ListAdminProjectsResult {
50
70
  nextCursor: ProjectCursor | null;
51
71
  }
52
72
 
73
+ const PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT = 'projects_workspace_slug_unique';
74
+
53
75
  function cursorWhere(cursor: ProjectCursor | undefined): SQL | undefined {
54
76
  if (!cursor) return undefined;
55
77
  return or(
@@ -60,37 +82,51 @@ function cursorWhere(cursor: ProjectCursor | undefined): SQL | undefined {
60
82
 
61
83
  export async function createProject(params: CreateProjectParams): Promise<Project> {
62
84
  const project = await db().transaction(async (tx) => {
63
- const [projectRow] = await tx
64
- .insert(projects)
65
- .values({
66
- workspaceId: params.workspaceId,
67
- sourceConnectionId: params.sourceConnectionId,
68
- sourceExternalRepositoryId: params.sourceExternalRepositoryId,
69
- name: params.name,
70
- })
71
- .onConflictDoNothing({
72
- target: [projects.sourceConnectionId, projects.sourceExternalRepositoryId],
73
- })
74
- .returning();
85
+ let projectRow: typeof projects.$inferSelect | undefined;
86
+ for (let attempt = 0; attempt < 2 && !projectRow; attempt += 1) {
87
+ try {
88
+ [projectRow] = await tx
89
+ .insert(projects)
90
+ .values({
91
+ workspaceId: params.workspaceId,
92
+ sourceConnectionId: params.sourceConnectionId,
93
+ sourceExternalRepositoryId: params.sourceExternalRepositoryId,
94
+ name: params.name,
95
+ slug: params.slug,
96
+ })
97
+ .onConflictDoNothing({
98
+ target: [projects.sourceConnectionId, projects.sourceExternalRepositoryId],
99
+ })
100
+ .returning();
101
+ } catch (error) {
102
+ if (isUniqueViolation(error, PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT)) {
103
+ throw new ProjectSlugConflictError(params.slug);
104
+ }
105
+ throw error;
106
+ }
75
107
 
76
- if (!projectRow) {
77
- const [conflict] = await tx
78
- .select()
79
- .from(projects)
80
- .where(
81
- and(
82
- eq(projects.sourceConnectionId, params.sourceConnectionId),
83
- eq(projects.sourceExternalRepositoryId, params.sourceExternalRepositoryId),
84
- ),
85
- )
86
- .limit(1);
87
- if (conflict) {
88
- throw new ProjectAlreadyExistsError(
89
- conflict.id,
90
- params.sourceConnectionId,
91
- params.sourceExternalRepositoryId,
92
- );
108
+ if (!projectRow) {
109
+ const [conflict] = await tx
110
+ .select()
111
+ .from(projects)
112
+ .where(
113
+ and(
114
+ eq(projects.sourceConnectionId, params.sourceConnectionId),
115
+ eq(projects.sourceExternalRepositoryId, params.sourceExternalRepositoryId),
116
+ ),
117
+ )
118
+ .limit(1);
119
+ if (conflict) {
120
+ throw new ProjectAlreadyExistsError(
121
+ conflict.id,
122
+ params.sourceConnectionId,
123
+ params.sourceExternalRepositoryId,
124
+ );
125
+ }
93
126
  }
127
+ }
128
+
129
+ if (!projectRow) {
94
130
  throw new Error('Insert returned no rows');
95
131
  }
96
132
 
@@ -100,6 +136,45 @@ export async function createProject(params: CreateProjectParams): Promise<Projec
100
136
  return project;
101
137
  }
102
138
 
139
+ export async function updateProject(
140
+ params: UpdateProjectParams,
141
+ options: {tx?: ProjectDatabase | ProjectTransaction | undefined} = {},
142
+ ): Promise<UpdateProjectResult | undefined> {
143
+ const executor = options.tx ?? db();
144
+
145
+ const [existingRow] = await executor
146
+ .select()
147
+ .from(projects)
148
+ .where(eq(projects.id, params.projectId))
149
+ .limit(1);
150
+ if (!existingRow) return undefined;
151
+
152
+ const nextName = params.name ?? existingRow.name;
153
+ const nextSlug = params.slug ?? existingRow.slug;
154
+ if (nextName === existingRow.name && nextSlug === existingRow.slug) {
155
+ return {project: toProject(existingRow), changed: false};
156
+ }
157
+
158
+ try {
159
+ const [row] = await executor
160
+ .update(projects)
161
+ .set({
162
+ ...(params.name !== undefined ? {name: params.name} : {}),
163
+ ...(params.slug !== undefined ? {slug: params.slug} : {}),
164
+ updatedAt: new Date(),
165
+ })
166
+ .where(eq(projects.id, params.projectId))
167
+ .returning();
168
+
169
+ return row ? {project: toProject(row), changed: true} : undefined;
170
+ } catch (error) {
171
+ if (isUniqueViolation(error, PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT)) {
172
+ throw new ProjectSlugConflictError(nextSlug);
173
+ }
174
+ throw error;
175
+ }
176
+ }
177
+
103
178
  export async function getProjectById(id: string): Promise<Project | undefined> {
104
179
  const rows = await db().select().from(projects).where(eq(projects.id, id)).limit(1);
105
180
 
@@ -134,6 +209,39 @@ export async function getProjectBySource(
134
209
  return toProject(row);
135
210
  }
136
211
 
212
+ export interface UpdateProjectSourceRepositoryParams extends GetProjectBySourceParams {
213
+ tx?: Executor;
214
+ sourceRepositoryOwner: string;
215
+ sourceRepositoryName: string;
216
+ sourceDefaultBranch: string;
217
+ }
218
+
219
+ export async function updateProjectSourceRepository(
220
+ params: UpdateProjectSourceRepositoryParams,
221
+ ): Promise<Project | undefined> {
222
+ const executor = params.tx ?? db();
223
+ const rows = await executor
224
+ .update(projects)
225
+ .set({
226
+ sourceRepositoryOwner: params.sourceRepositoryOwner,
227
+ sourceRepositoryName: params.sourceRepositoryName,
228
+ sourceDefaultBranch: params.sourceDefaultBranch,
229
+ updatedAt: new Date(),
230
+ })
231
+ .where(
232
+ and(
233
+ eq(projects.workspaceId, params.workspaceId),
234
+ eq(projects.sourceConnectionId, params.sourceConnectionId),
235
+ eq(projects.sourceExternalRepositoryId, params.sourceExternalRepositoryId),
236
+ ),
237
+ )
238
+ .returning();
239
+
240
+ const row = rows[0];
241
+ if (!row) return undefined;
242
+ return toProject(row);
243
+ }
244
+
137
245
  export async function requireProjectForWorkspace(params: {
138
246
  projectId: string;
139
247
  workspaceId: string;
@@ -144,12 +252,113 @@ export async function requireProjectForWorkspace(params: {
144
252
  return project;
145
253
  }
146
254
 
255
+ export interface ResolveCheckoutTargetParams {
256
+ workspaceId: string;
257
+ defaults: {
258
+ connectionId: string;
259
+ owner: string;
260
+ };
261
+ target: {project: string} | {connection?: string | undefined; repository: string};
262
+ }
263
+
264
+ export interface ResolvedCheckoutTarget {
265
+ projectId: string;
266
+ connectionId: string;
267
+ externalRepositoryId: string;
268
+ }
269
+
270
+ export interface UpdateProjectSourceMetadataParams {
271
+ workspaceId: string;
272
+ projectId: string;
273
+ sourceConnectionId: string;
274
+ sourceExternalRepositoryId: string;
275
+ sourceRepositoryOwner: string;
276
+ sourceRepositoryName: string;
277
+ sourceDefaultBranch: string;
278
+ }
279
+
280
+ export async function updateProjectSourceMetadata(
281
+ params: UpdateProjectSourceMetadataParams,
282
+ ): Promise<void> {
283
+ await db()
284
+ .update(projects)
285
+ .set({
286
+ sourceConnectionId: params.sourceConnectionId,
287
+ sourceExternalRepositoryId: params.sourceExternalRepositoryId,
288
+ sourceRepositoryOwner: params.sourceRepositoryOwner,
289
+ sourceRepositoryName: params.sourceRepositoryName,
290
+ sourceDefaultBranch: params.sourceDefaultBranch,
291
+ updatedAt: new Date(),
292
+ })
293
+ .where(and(eq(projects.workspaceId, params.workspaceId), eq(projects.id, params.projectId)));
294
+ }
295
+
296
+ export async function resolveCheckoutTarget(
297
+ params: ResolveCheckoutTargetParams,
298
+ ): Promise<ResolvedCheckoutTarget | undefined> {
299
+ const selection = {
300
+ projectId: projects.id,
301
+ connectionId: projects.sourceConnectionId,
302
+ externalRepositoryId: projects.sourceExternalRepositoryId,
303
+ };
304
+
305
+ if ('project' in params.target) {
306
+ const [project] = await db()
307
+ .select(selection)
308
+ .from(projects)
309
+ .where(
310
+ and(eq(projects.workspaceId, params.workspaceId), eq(projects.id, params.target.project)),
311
+ )
312
+ .limit(1);
313
+ return project;
314
+ }
315
+
316
+ const separator = params.target.repository.indexOf('/');
317
+ if (separator === 0 || separator === params.target.repository.length - 1) return undefined;
318
+
319
+ const repository =
320
+ separator === -1
321
+ ? {
322
+ connectionId: params.target.connection ?? params.defaults.connectionId,
323
+ owner: params.defaults.owner,
324
+ name: params.target.repository,
325
+ }
326
+ : params.target.repository.indexOf('/', separator + 1) === -1
327
+ ? {
328
+ connectionId: params.target.connection ?? params.defaults.connectionId,
329
+ owner: params.target.repository.slice(0, separator),
330
+ name: params.target.repository.slice(separator + 1),
331
+ }
332
+ : undefined;
333
+ if (repository === undefined) return undefined;
334
+
335
+ const matches = await db()
336
+ .select(selection)
337
+ .from(projects)
338
+ .where(
339
+ and(
340
+ eq(projects.workspaceId, params.workspaceId),
341
+ eq(projects.sourceConnectionId, repository.connectionId),
342
+ sql`lower(${projects.sourceRepositoryOwner}) = lower(${repository.owner})`,
343
+ sql`lower(${projects.sourceRepositoryName}) = lower(${repository.name})`,
344
+ ),
345
+ )
346
+ .limit(2);
347
+
348
+ // Owner/name isn't a unique key: renamed or reconnected repositories can leave
349
+ // more than one project row matching. Fail closed instead of picking arbitrarily.
350
+ return matches.length === 1 ? matches[0] : undefined;
351
+ }
352
+
147
353
  export async function listProjects(params: ListProjectsParams): Promise<ListProjectsResult> {
148
354
  const conditions = [eq(projects.workspaceId, params.workspaceId)];
149
355
  const cursorCondition = cursorWhere(params.cursor);
150
356
  if (cursorCondition) conditions.push(cursorCondition);
151
357
  if (params.search) {
152
- conditions.push(ilike(projects.name, `%${escapeIlikePattern(params.search)}%`));
358
+ const searchPattern = `%${escapeIlikePattern(params.search)}%`;
359
+ conditions.push(
360
+ or(ilike(projects.name, searchPattern), ilike(projects.slug, searchPattern)) as SQL,
361
+ );
153
362
  }
154
363
 
155
364
  const rows = await db()
@@ -1,4 +1,5 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {sql} from 'drizzle-orm';
2
3
  import {index, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
4
  import type {Project} from '#core/entities/project.js';
4
5
  import {pgTable} from './common.js';
@@ -10,7 +11,11 @@ export const projects = pgTable(
10
11
  workspaceId: uuid('workspace_id').notNull(),
11
12
  sourceConnectionId: uuid('source_connection_id').notNull(),
12
13
  sourceExternalRepositoryId: text('source_external_repository_id').notNull(),
14
+ sourceRepositoryOwner: text('source_repository_owner'),
15
+ sourceRepositoryName: text('source_repository_name'),
16
+ sourceDefaultBranch: text('source_default_branch'),
13
17
  name: text('name').notNull(),
18
+ slug: text('slug').notNull(),
14
19
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
15
20
  updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
16
21
  },
@@ -19,7 +24,14 @@ export const projects = pgTable(
19
24
  table.sourceConnectionId,
20
25
  table.sourceExternalRepositoryId,
21
26
  ),
27
+ uniqueIndex('projects_workspace_slug_unique').on(table.workspaceId, table.slug),
22
28
  index('projects_workspace_created_id_idx').on(table.workspaceId, table.createdAt, table.id),
29
+ index('projects_source_repository_lookup_idx').on(
30
+ table.workspaceId,
31
+ table.sourceConnectionId,
32
+ sql`lower(${table.sourceRepositoryOwner})`,
33
+ sql`lower(${table.sourceRepositoryName})`,
34
+ ),
23
35
  ],
24
36
  );
25
37
 
@@ -32,7 +44,11 @@ export function toProject(row: ProjectDb): Project {
32
44
  workspaceId: row.workspaceId,
33
45
  sourceConnectionId: row.sourceConnectionId,
34
46
  sourceExternalRepositoryId: row.sourceExternalRepositoryId,
47
+ sourceRepositoryOwner: row.sourceRepositoryOwner,
48
+ sourceRepositoryName: row.sourceRepositoryName,
49
+ sourceDefaultBranch: row.sourceDefaultBranch,
35
50
  name: row.name,
51
+ slug: row.slug,
36
52
  createdAt: row.createdAt,
37
53
  updatedAt: row.updatedAt,
38
54
  };
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import {fileURLToPath} from 'node:url';
3
3
  import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
4
4
  import {
5
5
  INTEGRATION_SOURCE_COMMIT_PUSHED,
6
+ INTEGRATION_SOURCE_REPOSITORY_UPDATED,
6
7
  type IntegrationsEventMap,
7
8
  } from '@shipfox/api-integration-core-dto';
8
9
  import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/inter-module';
@@ -13,7 +14,7 @@ import {registerProjectsServiceMetrics} from '#metrics/index.js';
13
14
  import {projectsE2eRoutes} from '#presentation/e2eRoutes/index.js';
14
15
  import {createProjectRoutes} from '#presentation/index.js';
15
16
  import {createProjectsInterModulePresentation} from '#presentation/inter-module.js';
16
- import {onSourceCommitPushed} from '#presentation/subscribers/index.js';
17
+ import {onSourceCommitPushed, onSourceRepositoryUpdated} from '#presentation/subscribers/index.js';
17
18
  import {createProjectsMaintenanceActivities} from '#temporal/activities/index.js';
18
19
  import {PROJECTS_MAINTENANCE_TASK_QUEUE} from '#temporal/constants.js';
19
20
 
@@ -28,6 +29,8 @@ export {
28
29
  ProjectAccessDeniedError,
29
30
  ProjectAlreadyExistsError,
30
31
  ProjectNotFoundError,
32
+ ProjectSlugConflictError,
33
+ updateProjectDetails,
31
34
  } from '#core/index.js';
32
35
  export type {GetProjectBySourceParams} from '#db/index.js';
33
36
  export {
@@ -59,8 +62,11 @@ export function createProjectsModule({
59
62
  e2eRoutes: [projectsE2eRoutes],
60
63
  metrics: registerProjectsServiceMetrics,
61
64
  publishers: [{name: 'projects', table: projectsOutbox, db, eventSchemas: projectsEventSchemas}],
62
- interModulePresentations: [createProjectsInterModulePresentation()],
63
- subscribers: [subscriber(INTEGRATION_SOURCE_COMMIT_PUSHED, onSourceCommitPushed)],
65
+ interModulePresentations: [createProjectsInterModulePresentation({integrations})],
66
+ subscribers: [
67
+ subscriber(INTEGRATION_SOURCE_COMMIT_PUSHED, onSourceCommitPushed),
68
+ subscriber(INTEGRATION_SOURCE_REPOSITORY_UPDATED, onSourceRepositoryUpdated),
69
+ ],
64
70
  workers: [
65
71
  {
66
72
  taskQueue: PROJECTS_MAINTENANCE_TASK_QUEUE,
@@ -6,6 +6,7 @@ export function toProjectDto(project: Project) {
6
6
  id: project.id,
7
7
  workspace_id: project.workspaceId,
8
8
  name: project.name,
9
+ slug: project.slug,
9
10
  source: {
10
11
  connection_id: project.sourceConnectionId,
11
12
  external_repository_id: project.sourceExternalRepositoryId,
@@ -6,6 +6,7 @@ import {projectsE2eRoutes} from './index.js';
6
6
 
7
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
8
  const E2E_SOURCE_RE = /^e2e:/u;
9
+ const GENERATED_PROJECT_SLUG_RE = /^e2e-project-/u;
9
10
 
10
11
  describe('projects E2E routes', () => {
11
12
  afterEach(async () => {
@@ -34,6 +35,28 @@ describe('projects E2E routes', () => {
34
35
  expect(res.json().source.external_repository_id).toMatch(E2E_SOURCE_RE);
35
36
  });
36
37
 
38
+ test('generates distinct slugs for repeated names in one workspace', async () => {
39
+ const workspaceId = crypto.randomUUID();
40
+ const app = await createApp({routes: [projectsE2eRoutes], swagger: false});
41
+
42
+ const first = await app.inject({
43
+ method: 'POST',
44
+ url: '/projects',
45
+ payload: {workspace_id: workspaceId, name: 'E2E Project'},
46
+ });
47
+ const second = await app.inject({
48
+ method: 'POST',
49
+ url: '/projects',
50
+ payload: {workspace_id: workspaceId, name: 'E2E Project'},
51
+ });
52
+
53
+ expect(first.statusCode).toBe(201);
54
+ expect(second.statusCode).toBe(201);
55
+ expect(first.json().slug).toMatch(GENERATED_PROJECT_SLUG_RE);
56
+ expect(second.json().slug).toMatch(GENERATED_PROJECT_SLUG_RE);
57
+ expect(second.json().slug).not.toBe(first.json().slug);
58
+ });
59
+
37
60
  test('preserves explicit synthetic source values', async () => {
38
61
  const workspaceId = crypto.randomUUID();
39
62
  const sourceConnectionId = crypto.randomUUID();
@@ -1,10 +1,11 @@
1
1
  import {randomUUID} from 'node:crypto';
2
+ import {slugifyName, withSlugSuffix} from '@shipfox/api-common-dto';
2
3
  import {
3
4
  e2eCreateProjectBodySchema,
4
5
  e2eCreateProjectResponseSchema,
5
6
  } from '@shipfox/api-projects-dto';
6
7
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
- import {ProjectAlreadyExistsError} from '#core/index.js';
8
+ import {ProjectAlreadyExistsError, ProjectSlugConflictError} from '#core/index.js';
8
9
  import {createProject} from '#db/index.js';
9
10
  import {toProjectDto} from '#presentation/dto/index.js';
10
11
 
@@ -12,6 +13,13 @@ function syntheticExternalRepositoryId(): string {
12
13
  return `e2e:${randomUUID()}`;
13
14
  }
14
15
 
16
+ const MAX_GENERATED_SLUG_ATTEMPTS = 5;
17
+
18
+ function generatedProjectSlug(slugBase: string): string {
19
+ const suffix = Number.parseInt(randomUUID().replaceAll('-', '').slice(0, 8), 16);
20
+ return withSlugSuffix(slugBase, suffix);
21
+ }
22
+
15
23
  export const createE2eProjectRoute = defineRoute({
16
24
  method: 'POST',
17
25
  path: '/',
@@ -33,18 +41,39 @@ export const createE2eProjectRoute = defineRoute({
33
41
  status: 409,
34
42
  });
35
43
  }
44
+ if (error instanceof ProjectSlugConflictError) {
45
+ throw new ClientError('Project slug already exists', 'slug-conflict', {status: 409});
46
+ }
36
47
  throw error;
37
48
  },
38
49
  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
- });
50
+ const requestedSlug = request.body.slug;
51
+ const slugBase = slugifyName(request.body.name, {fallback: 'project'});
52
+ let slug = requestedSlug ?? generatedProjectSlug(slugBase);
53
+
54
+ for (let attempt = 0; attempt < MAX_GENERATED_SLUG_ATTEMPTS; attempt += 1) {
55
+ try {
56
+ const project = await createProject({
57
+ workspaceId: request.body.workspace_id,
58
+ name: request.body.name,
59
+ slug,
60
+ sourceConnectionId: request.body.source_connection_id ?? randomUUID(),
61
+ sourceExternalRepositoryId:
62
+ request.body.source_external_repository_id ?? syntheticExternalRepositoryId(),
63
+ });
64
+
65
+ reply.code(201);
66
+ return toProjectDto(project);
67
+ } catch (error) {
68
+ const shouldRetrySlug =
69
+ requestedSlug === undefined &&
70
+ error instanceof ProjectSlugConflictError &&
71
+ attempt < MAX_GENERATED_SLUG_ATTEMPTS - 1;
72
+ if (!shouldRetrySlug) throw error;
73
+ slug = generatedProjectSlug(slugBase);
74
+ }
75
+ }
46
76
 
47
- reply.code(201);
48
- return toProjectDto(project);
77
+ throw new Error('Unable to generate a unique project slug');
49
78
  },
50
79
  });