@@ -9,13 +9,15 @@ import { inject, injectable } from "inversify";
9
9
import { TypeORM } from "./typeorm" ;
10
10
import { Repository } from "typeorm" ;
11
11
import { v4 as uuidv4 } from "uuid" ;
12
+ import { randomBytes } from "crypto" ;
12
13
import { TeamDB } from "../team-db" ;
13
14
import { DBTeam } from "./entity/db-team" ;
14
15
import { DBTeamMembership } from "./entity/db-team-membership" ;
15
16
import { DBUser } from "./entity/db-user" ;
16
17
import { DBTeamMembershipInvite } from "./entity/db-team-membership-invite" ;
17
18
import { ResponseError } from "vscode-jsonrpc" ;
18
19
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error" ;
20
+ import slugify from "slugify" ;
19
21
20
22
@injectable ( )
21
23
export class TeamDBImpl implements TeamDB {
@@ -121,18 +123,53 @@ export class TeamDBImpl implements TeamDB {
121
123
return soleOwnedTeams ;
122
124
}
123
125
124
- public async updateTeam ( teamId : string , team : Pick < Team , "name" > ) : Promise < Team > {
125
- const teamRepo = await this . getTeamRepo ( ) ;
126
- const existingTeam = await teamRepo . findOne ( { id : teamId , deleted : false , markedDeleted : false } ) ;
127
- if ( ! existingTeam ) {
128
- throw new ResponseError ( ErrorCodes . NOT_FOUND , "Organization not found" ) ;
129
- }
126
+ public async updateTeam ( teamId : string , team : Pick < Team , "name" | "slug" > ) : Promise < Team > {
130
127
const name = team . name && team . name . trim ( ) ;
131
- if ( ! name || name . length === 0 || name . length > 32 ) {
132
- throw new ResponseError ( ErrorCodes . INVALID_VALUE , "The name must be between 1 and 32 characters long" ) ;
128
+ const slug = team . slug && team . slug . trim ( ) ;
129
+ if ( ! name && ! slug ) {
130
+ throw new ResponseError ( ErrorCodes . BAD_REQUEST , "No update provided" ) ;
133
131
}
134
- existingTeam . name = name ;
135
- return teamRepo . save ( existingTeam ) ;
132
+
133
+ // Storing entry in a TX to avoid potential slug dupes caused by racing requests.
134
+ const em = await this . getEntityManager ( ) ;
135
+ return await em . transaction < DBTeam > ( async ( em ) => {
136
+ const teamRepo = em . getRepository < DBTeam > ( DBTeam ) ;
137
+
138
+ const existingTeam = await teamRepo . findOne ( { id : teamId , deleted : false , markedDeleted : false } ) ;
139
+ if ( ! existingTeam ) {
140
+ throw new ResponseError ( ErrorCodes . NOT_FOUND , "Organization not found" ) ;
141
+ }
142
+
143
+ // no changes
144
+ if ( existingTeam . name === name && existingTeam . slug === slug ) {
145
+ return existingTeam ;
146
+ }
147
+
148
+ if ( ! ! name ) {
149
+ if ( name . length > 32 ) {
150
+ throw new ResponseError (
151
+ ErrorCodes . INVALID_VALUE ,
152
+ "The name must be between 1 and 32 characters long" ,
153
+ ) ;
154
+ }
155
+ existingTeam . name = name ;
156
+ }
157
+ if ( ! ! slug && existingTeam . slug != slug ) {
158
+ if ( slug . length > 63 ) {
159
+ throw new ResponseError ( ErrorCodes . INVALID_VALUE , "Slug must be between 1 and 63 characters long" ) ;
160
+ }
161
+ if ( ! / ^ [ A - Z a - z 0 - 9 - ] + $ / . test ( slug ) ) {
162
+ throw new ResponseError ( ErrorCodes . BAD_REQUEST , "Slug must contain only letters, or numbers" ) ;
163
+ }
164
+ const anotherTeamWithThatSlug = await teamRepo . findOne ( { slug, deleted : false , markedDeleted : false } ) ;
165
+ if ( anotherTeamWithThatSlug ) {
166
+ throw new ResponseError ( ErrorCodes . INVALID_VALUE , "Slug must be unique" ) ;
167
+ }
168
+ existingTeam . slug = slug ;
169
+ }
170
+
171
+ return teamRepo . save ( existingTeam ) ;
172
+ } ) ;
136
173
}
137
174
138
175
public async createTeam ( userId : string , name : string ) : Promise < Team > {
@@ -146,13 +183,27 @@ export class TeamDBImpl implements TeamDB {
146
183
) ;
147
184
}
148
185
149
- const teamRepo = await this . getTeamRepo ( ) ;
150
- const team : Team = {
151
- id : uuidv4 ( ) ,
152
- name,
153
- creationTime : new Date ( ) . toISOString ( ) ,
154
- } ;
155
- await teamRepo . save ( team ) ;
186
+ let slug = slugify ( name , { lower : true } ) ;
187
+
188
+ // Storing new entry in a TX to avoid potential dupes caused by racing requests.
189
+ const em = await this . getEntityManager ( ) ;
190
+ const team = await em . transaction < DBTeam > ( async ( em ) => {
191
+ const teamRepo = em . getRepository < DBTeam > ( DBTeam ) ;
192
+
193
+ const existingTeam = await teamRepo . findOne ( { slug, deleted : false , markedDeleted : false } ) ;
194
+ if ( ! ! existingTeam ) {
195
+ slug = slug + "-" + randomBytes ( 4 ) . toString ( "hex" ) ;
196
+ }
197
+
198
+ const team : Team = {
199
+ id : uuidv4 ( ) ,
200
+ name,
201
+ slug,
202
+ creationTime : new Date ( ) . toISOString ( ) ,
203
+ } ;
204
+ return await teamRepo . save ( team ) ;
205
+ } ) ;
206
+
156
207
const membershipRepo = await this . getMembershipRepo ( ) ;
157
208
await membershipRepo . save ( {
158
209
id : uuidv4 ( ) ,
0 commit comments