11import sql from "@/lib/db" ;
22import { AuthFlowError } from "@/lib/auth/errors" ;
33import { INTERNAL_AUTH_PROVIDER , resolveAppSessionIdentity } from "@/lib/auth/identity" ;
4+ import { logRepositoryOperation } from "@/lib/repository-logging" ;
45import {
56 parseCountryCode ,
67 parseFavoriteGenres ,
@@ -44,6 +45,8 @@ type CompleteSignupInput = {
4445 userId : string ;
4546} ;
4647
48+ const REPOSITORY_MODULE = "auth.onboarding" ;
49+
4750function mapCompletedUser ( row : SignupUserRow ) : AuthUser {
4851 const sessionIdentity = resolveAppSessionIdentity ( row ) ;
4952
@@ -68,152 +71,169 @@ function mapCompletedUser(row: SignupUserRow): AuthUser {
6871export async function completeSignup (
6972 input : CompleteSignupInput ,
7073) : Promise < AuthUser > {
71- const nickname = parseNickname ( input . nickname ) ;
72- const gender = parseUserGender ( input . gender ) ;
73- const countryCode = parseCountryCode ( input . countryCode ) ;
74- const favoriteGenres = parseFavoriteGenres ( input . favoriteGenres ) ;
75- const invitationCodeHash = hashInvitationCode ( input . invitationCode ) ;
76-
77- try {
78- return await sql . begin ( async ( tx ) => {
79- const query = tx as unknown as typeof sql ;
80-
81- const [ user ] = await query < SignupUserRow [ ] > `
82- select
83- id::text as id,
84- provider,
85- provider_user_id as "providerUserId",
86- email::text as email,
87- name,
88- image_url as "imageUrl",
89- nickname,
90- gender,
91- country_code as "countryCode",
92- favorite_genres as "favoriteGenres",
93- signup_completed_at as "signupCompletedAt"
94- from bookapp.users
95- where id = ${ input . userId } ::uuid
96- for update
97- ` ;
98-
99- if ( ! user ) {
100- throw new AuthFlowError ( "UNAUTHORIZED" , "Sign in to continue." ) ;
101- }
102-
103- if ( user . provider === INTERNAL_AUTH_PROVIDER ) {
104- throw new AuthFlowError (
105- "FORBIDDEN" ,
106- "Internal admins cannot complete reader signup." ,
107- ) ;
108- }
109-
110- if ( user . signupCompletedAt ) {
111- throw new AuthFlowError ( "CONFLICT" , "Signup is already complete." ) ;
112- }
113-
114- const [ invitationCode ] = await query < InvitationCodeLookupRow [ ] > `
115- select
116- invitation_codes.id::text as id,
117- invitation_codes.purpose,
118- invitation_codes.is_active as "isActive",
119- invitation_codes.expires_at as "expiresAt",
120- invitation_codes.max_uses as "maxUses"
121- from bookapp.invitation_codes
122- where invitation_codes.code_hash = ${ invitationCodeHash }
123- and invitation_codes.purpose = 'BETA_SIGNUP'
124- for update
125- ` ;
126-
127- if ( ! invitationCode ) {
128- throw new AuthFlowError (
129- "VALIDATION" ,
130- "Enter a valid beta invitation code." ,
131- ) ;
132- }
133-
134- const [ redemptionCountResult ] = await query < { redemptionCount : number } [ ] > `
135- select count(*)::int as "redemptionCount"
136- from bookapp.invitation_code_redemptions
137- where code_id = ${ invitationCode . id } ::uuid
138- ` ;
139-
140- const invitationCodeStatus = resolveInvitationCodeStatus ( {
141- ...invitationCode ,
142- redemptionCount : redemptionCountResult ?. redemptionCount ?? 0 ,
143- } ) ;
144- switch ( invitationCodeStatus ) {
145- case "INACTIVE" :
146- throw new AuthFlowError (
147- "FORBIDDEN" ,
148- "This invitation code is inactive." ,
149- ) ;
150- case "EXPIRED" :
151- throw new AuthFlowError (
152- "FORBIDDEN" ,
153- "This invitation code has expired." ,
154- ) ;
155- case "EXHAUSTED" :
156- throw new AuthFlowError (
157- "FORBIDDEN" ,
158- "This invitation code has no uses remaining." ,
159- ) ;
160- default :
161- break ;
74+ return logRepositoryOperation (
75+ {
76+ context : {
77+ countryCode : input . countryCode ,
78+ favoriteGenreCount : input . favoriteGenres . length ,
79+ gender : input . gender ,
80+ userId : input . userId ,
81+ } ,
82+ module : REPOSITORY_MODULE ,
83+ operation : "completeSignup" ,
84+ transactional : true ,
85+ } ,
86+ async ( ) => {
87+ const nickname = parseNickname ( input . nickname ) ;
88+ const gender = parseUserGender ( input . gender ) ;
89+ const countryCode = parseCountryCode ( input . countryCode ) ;
90+ const favoriteGenres = parseFavoriteGenres ( input . favoriteGenres ) ;
91+ const invitationCodeHash = hashInvitationCode ( input . invitationCode ) ;
92+
93+ try {
94+ return await sql . begin ( async ( tx ) => {
95+ const query = tx as unknown as typeof sql ;
96+
97+ const [ user ] = await query < SignupUserRow [ ] > `
98+ select
99+ id::text as id,
100+ provider,
101+ provider_user_id as "providerUserId",
102+ email::text as email,
103+ name,
104+ image_url as "imageUrl",
105+ nickname,
106+ gender,
107+ country_code as "countryCode",
108+ favorite_genres as "favoriteGenres",
109+ signup_completed_at as "signupCompletedAt"
110+ from bookapp.users
111+ where id = ${ input . userId } ::uuid
112+ for update
113+ ` ;
114+
115+ if ( ! user ) {
116+ throw new AuthFlowError ( "UNAUTHORIZED" , "Sign in to continue." ) ;
117+ }
118+
119+ if ( user . provider === INTERNAL_AUTH_PROVIDER ) {
120+ throw new AuthFlowError (
121+ "FORBIDDEN" ,
122+ "Internal admins cannot complete reader signup." ,
123+ ) ;
124+ }
125+
126+ if ( user . signupCompletedAt ) {
127+ throw new AuthFlowError ( "CONFLICT" , "Signup is already complete." ) ;
128+ }
129+
130+ const [ invitationCode ] = await query < InvitationCodeLookupRow [ ] > `
131+ select
132+ invitation_codes.id::text as id,
133+ invitation_codes.purpose,
134+ invitation_codes.is_active as "isActive",
135+ invitation_codes.expires_at as "expiresAt",
136+ invitation_codes.max_uses as "maxUses"
137+ from bookapp.invitation_codes
138+ where invitation_codes.code_hash = ${ invitationCodeHash }
139+ and invitation_codes.purpose = 'BETA_SIGNUP'
140+ for update
141+ ` ;
142+
143+ if ( ! invitationCode ) {
144+ throw new AuthFlowError (
145+ "VALIDATION" ,
146+ "Enter a valid beta invitation code." ,
147+ ) ;
148+ }
149+
150+ const [ redemptionCountResult ] = await query <
151+ { redemptionCount : number } [ ]
152+ > `
153+ select count(*)::int as "redemptionCount"
154+ from bookapp.invitation_code_redemptions
155+ where code_id = ${ invitationCode . id } ::uuid
156+ ` ;
157+
158+ const invitationCodeStatus = resolveInvitationCodeStatus ( {
159+ ...invitationCode ,
160+ redemptionCount : redemptionCountResult ?. redemptionCount ?? 0 ,
161+ } ) ;
162+ switch ( invitationCodeStatus ) {
163+ case "INACTIVE" :
164+ throw new AuthFlowError (
165+ "FORBIDDEN" ,
166+ "This invitation code is inactive." ,
167+ ) ;
168+ case "EXPIRED" :
169+ throw new AuthFlowError (
170+ "FORBIDDEN" ,
171+ "This invitation code has expired." ,
172+ ) ;
173+ case "EXHAUSTED" :
174+ throw new AuthFlowError (
175+ "FORBIDDEN" ,
176+ "This invitation code has no uses remaining." ,
177+ ) ;
178+ default :
179+ break ;
180+ }
181+
182+ const [ updatedUser ] = await query < SignupUserRow [ ] > `
183+ update bookapp.users
184+ set
185+ nickname = ${ nickname } ,
186+ gender = ${ gender } ,
187+ country_code = ${ countryCode } ,
188+ favorite_genres = ${ sql . array ( [ ...favoriteGenres ] ) } ,
189+ signup_completed_at = now(),
190+ updated_at = now()
191+ where id = ${ input . userId } ::uuid
192+ returning
193+ id::text as id,
194+ provider,
195+ provider_user_id as "providerUserId",
196+ email::text as email,
197+ name,
198+ image_url as "imageUrl",
199+ nickname,
200+ gender,
201+ country_code as "countryCode",
202+ favorite_genres as "favoriteGenres",
203+ signup_completed_at as "signupCompletedAt"
204+ ` ;
205+
206+ await query `
207+ insert into bookapp.invitation_code_redemptions (
208+ code_id,
209+ user_id
210+ )
211+ values (
212+ ${ invitationCode . id } ::uuid,
213+ ${ input . userId } ::uuid
214+ )
215+ ` ;
216+
217+ if ( ! updatedUser ) {
218+ throw new AuthFlowError ( "UNAUTHORIZED" , "Sign in to continue." ) ;
219+ }
220+
221+ return mapCompletedUser ( updatedUser ) ;
222+ } ) ;
223+ } catch ( error ) {
224+ if (
225+ typeof error === "object" &&
226+ error !== null &&
227+ "code" in error &&
228+ error . code === "23505" &&
229+ "constraint_name" in error &&
230+ error . constraint_name === "users_nickname_uniq"
231+ ) {
232+ throw new AuthFlowError ( "CONFLICT" , "This nickname is already taken." ) ;
233+ }
234+
235+ throw error ;
162236 }
163-
164- const [ updatedUser ] = await query < SignupUserRow [ ] > `
165- update bookapp.users
166- set
167- nickname = ${ nickname } ,
168- gender = ${ gender } ,
169- country_code = ${ countryCode } ,
170- favorite_genres = ${ sql . array ( [ ...favoriteGenres ] ) } ,
171- signup_completed_at = now(),
172- updated_at = now()
173- where id = ${ input . userId } ::uuid
174- returning
175- id::text as id,
176- provider,
177- provider_user_id as "providerUserId",
178- email::text as email,
179- name,
180- image_url as "imageUrl",
181- nickname,
182- gender,
183- country_code as "countryCode",
184- favorite_genres as "favoriteGenres",
185- signup_completed_at as "signupCompletedAt"
186- ` ;
187-
188- await query `
189- insert into bookapp.invitation_code_redemptions (
190- code_id,
191- user_id
192- )
193- values (
194- ${ invitationCode . id } ::uuid,
195- ${ input . userId } ::uuid
196- )
197- ` ;
198-
199- if ( ! updatedUser ) {
200- throw new AuthFlowError ( "UNAUTHORIZED" , "Sign in to continue." ) ;
201- }
202-
203- return mapCompletedUser ( updatedUser ) ;
204- } ) ;
205- } catch ( error ) {
206- if (
207- typeof error === "object" &&
208- error !== null &&
209- "code" in error &&
210- error . code === "23505" &&
211- "constraint_name" in error &&
212- error . constraint_name === "users_nickname_uniq"
213- ) {
214- throw new AuthFlowError ( "CONFLICT" , "This nickname is already taken." ) ;
215- }
216-
217- throw error ;
218- }
237+ } ,
238+ ) ;
219239}
0 commit comments