Skip to content

Commit 8d2a247

Browse files
committed
feat: improve onboarding flow
1 parent 3f16713 commit 8d2a247

35 files changed

+932
-163
lines changed

api-schema.graphql

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,29 @@ type Mutation {
120120
syncUserProfile: JSON
121121
userDeleteIdentity(identityId: String!): Boolean
122122
userLinkIdentity(input: IdentityUserLinkInput!): Identity
123+
userOnboardingCreateProfile(publicKey: String!): [Int!]
124+
userOnboardingCustomizeProfile(avatarUrl: String!, username: String!): Boolean
123125
userUpdateUser(input: UserUserUpdateInput!): User
124126
userVerifyIdentityChallenge(input: IdentityVerifyChallengeInput!): IdentityChallenge
125127
}
126128

129+
type OnboardingRequirements {
130+
profileAccount: String
131+
socialIdentities: Int
132+
solanaIdentities: Int
133+
step: OnboardingStep!
134+
validAvatarUrl: Boolean
135+
validUsername: Boolean
136+
}
137+
138+
enum OnboardingStep {
139+
CreateProfile
140+
CustomizeProfile
141+
Finished
142+
LinkSocialIdentities
143+
LinkSolanaWallets
144+
}
145+
127146
type PagingMeta {
128147
currentPage: Int!
129148
isFirstPage: Boolean!
@@ -138,7 +157,6 @@ type PubkeyProfile {
138157
authorities: [String!]!
139158
avatarUrl: String!
140159
bump: Int!
141-
feePayer: String!
142160
identities: [PubkeyProfileIdentity!]!
143161
publicKey: String!
144162
username: String!
@@ -170,6 +188,7 @@ type Query {
170188
userFindOneUser(username: String!): User
171189
userGetOnboardingAvatarUrls: [String!]
172190
userGetOnboardingUsernames: [String!]
191+
userOnboardingRequirements: OnboardingRequirements
173192
userRequestIdentityChallenge(input: IdentityRequestChallengeInput!): IdentityChallenge
174193
}
175194

libs/api/core/data-access/src/lib/api-core.service.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable } from '@nestjs/common'
22
import { EventEmitter2 } from '@nestjs/event-emitter'
3-
import { IdentityProvider } from '@prisma/client'
3+
import { IdentityProvider, Prisma } from '@prisma/client'
44
import { ApiCorePrismaClient, prismaClient } from './api-core-prisma-client'
55
import { ApiCoreConfigService } from './config/api-core-config.service'
66
import { slugifyId } from './helpers/slugify-id'
@@ -11,6 +11,17 @@ export class ApiCoreService {
1111
readonly data: ApiCorePrismaClient = prismaClient
1212
constructor(readonly config: ApiCoreConfigService, readonly eventEmitter: EventEmitter2) {}
1313

14+
async ensureUserById(userId: string) {
15+
const user = await this.data.user.findUnique({
16+
where: { id: userId },
17+
include: { identities: true },
18+
})
19+
if (!user) {
20+
throw new Error(`User ${userId} not found`)
21+
}
22+
return user
23+
}
24+
1425
async findUserByIdentity({ provider, providerId }: { provider: IdentityProvider; providerId: string }) {
1526
return this.data.identity.findUnique({
1627
where: { provider_providerId: { provider, providerId } },
@@ -45,4 +56,8 @@ export class ApiCoreService {
4556
include: { identities: true },
4657
})
4758
}
59+
60+
async updateUserById(userId: string, data: Prisma.UserUpdateInput) {
61+
return this.data.user.update({ where: { id: userId }, data })
62+
}
4863
}

libs/api/core/data-access/src/lib/config/api-core-config.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ export class ApiCoreConfigService {
238238
return '/api'
239239
}
240240

241+
get pubkeyProtocolCommunity(): string {
242+
return this.service.get<string>('pubkeyProtocolCommunity') as string
243+
}
244+
241245
get pubkeyProtocolSigner(): Keypair {
242246
return this.service.get<Keypair>('pubkeyProtocolSigner') as Keypair
243247
}

libs/api/core/data-access/src/lib/config/configuration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface ApiCoreConfig {
6464
jwtSecret: string
6565
port: number
6666
sessionSecret: string
67+
pubkeyProtocolCommunity: string
6768
pubkeyProtocolSigner: Keypair
6869
pubkeyProtocolSignerMinimalBalance: number
6970
solanaEndpoint: string
@@ -106,6 +107,7 @@ export function configuration(): ApiCoreConfig {
106107
host: process.env['HOST'] as string,
107108
jwtSecret: process.env['JWT_SECRET'] as string,
108109
port: parseInt(process.env['PORT'] as string, 10) || 3000,
110+
pubkeyProtocolCommunity: process.env['PUBKEY_PROTOCOL_COMMUNITY'] as string,
109111
pubkeyProtocolSigner: getKeypairFromByteArray(
110112
JSON.parse(process.env['PUBKEY_PROTOCOL_SIGNER_SECRET_KEY'] as string),
111113
),

libs/api/core/data-access/src/lib/config/validation-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const validationSchema = Joi.object({
4747
HOST: Joi.string().default('0.0.0.0'),
4848
NODE_ENV: Joi.string().valid('development', 'production', 'test', 'provision').default('development'),
4949
PORT: Joi.number().default(3000),
50+
PUBKEY_PROTOCOL_COMMUNITY: Joi.string().required(),
5051
PUBKEY_PROTOCOL_SIGNER_SECRET_KEY: Joi.string().required(),
5152
PUBKEY_PROTOCOL_SIGNER_MINIMAL_BALANCE: Joi.number().default(1),
5253
SESSION_SECRET: Joi.string().required(),
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export * from './lib/api-onboarding.data-access.module'
22
export * from './lib/api-onboarding.service'
3+
export * from './lib/entity/onboarding-step'
4+
export * from './lib/entity/onboarding.entity'

libs/api/onboarding/data-access/src/lib/api-onboarding.data-access.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Module } from '@nestjs/common'
22
import { ApiCoreDataAccessModule } from '@pubkey-network/api-core-data-access'
3+
import { ApiProtocolDataAccessModule } from '@pubkey-network/api-protocol-data-access'
34
import { ApiOnboardingService } from './api-onboarding.service'
45

56
@Module({
6-
imports: [ApiCoreDataAccessModule],
7+
imports: [ApiCoreDataAccessModule, ApiProtocolDataAccessModule],
78
providers: [ApiOnboardingService],
89
exports: [ApiOnboardingService],
910
})

libs/api/onboarding/data-access/src/lib/api-onboarding.service.ts

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
1-
import { Injectable } from '@nestjs/common'
1+
import { Injectable, Logger } from '@nestjs/common'
22
import { IdentityProvider } from '@prisma/client'
33
import { ApiCoreService, ellipsify } from '@pubkey-network/api-core-data-access'
4+
import { ApiProtocolService } from '@pubkey-network/api-protocol-data-access'
5+
import { OnboardingStep } from './entity/onboarding-step'
6+
import { OnboardingRequirements } from './entity/onboarding.entity'
47

58
@Injectable()
69
export class ApiOnboardingService {
7-
constructor(private readonly core: ApiCoreService) {}
10+
readonly socialProviders = [
11+
IdentityProvider.Discord,
12+
IdentityProvider.Github,
13+
IdentityProvider.Google,
14+
// IdentityProvider.Telegram,
15+
IdentityProvider.X,
16+
]
17+
18+
private readonly logger = new Logger(ApiOnboardingService.name)
19+
constructor(private readonly core: ApiCoreService, private readonly protocol: ApiProtocolService) {}
820

921
async getOnboardingUsernames(userId: string) {
10-
const found = await this.core.data.user.findUnique({
11-
where: { id: userId },
12-
include: { identities: true },
13-
})
14-
if (!found) {
15-
throw new Error(`User ${userId} not found`)
16-
}
17-
const usernames = found.identities
22+
const user = await this.core.ensureUserById(userId)
23+
const rawUsernames = user.identities
1824
.filter((i) => i?.profile)
1925
.map((i) => i.profile as { username?: string })
2026
// Remove any identities that don't have a username
2127
.filter((i) => i.username)
2228
// Take the username property
2329
.map((i) => i.username as string)
24-
// Take the first part of any email addresses
25-
.map((i) => i?.split('@')[0])
26-
// Convert any special characters to lowercase
27-
.map((i) => i.replace(/[^a-z0-9]/gi, '_').toLowerCase())
30+
31+
const usernames = cleanupUsernames(rawUsernames)
2832

2933
// For all usernames with an underscore, also offer the username without the underscore
3034
for (const username of usernames) {
@@ -33,32 +37,29 @@ export class ApiOnboardingService {
3337
}
3438
}
3539

36-
const wallets = found.identities
40+
// Remove any duplicates and sort the usernames
41+
const sortedUsernames = Array.from(new Set(usernames)).sort()
42+
43+
// Create usernames based on Solana wallets for users that like to be pseudonymous
44+
const wallets = user.identities
3745
// Remove any identities that are not Solana wallets
3846
.filter((i) => i?.provider === IdentityProvider.Solana)
3947
// Take the providerId property
4048
.map((i) => i.providerId as string)
4149
// Convert any special characters to lowercase
4250
.map((i) => ellipsify(i, 4, '__').toLowerCase())
4351

44-
usernames.push(...wallets)
52+
// Remove any duplicates and sort the wallets
53+
const sortedWallets = Array.from(new Set(wallets)).sort()
4554

46-
// Remove any duplicates and sort the usernames
47-
return Array.from(new Set(usernames)).sort()
55+
return [...sortedUsernames, ...sortedWallets]
4856
}
4957

5058
async getOnboardingAvatarUrls(userId: string) {
51-
const found = await this.core.data.user.findUnique({
52-
where: { id: userId },
53-
include: { identities: true },
54-
})
55-
if (!found) {
56-
throw new Error(`User ${userId} not found`)
57-
}
58-
59+
const user = await this.core.ensureUserById(userId)
5960
const usernames: string[] = []
6061

61-
const avatarUrls = found.identities
62+
const avatarUrls = user.identities
6263
.filter((i) => i?.profile)
6364
.map((i) => i.profile as { avatarUrl?: string; username?: string })
6465
.map((i) => {
@@ -76,14 +77,98 @@ export class ApiOnboardingService {
7677
const cleaned = cleanupUsernames(usernames)
7778

7879
for (const username of cleaned) {
80+
avatarUrls.push(
81+
`https://api.dicebear.com/9.x/avataaars/svg?backgroundColor=b6e3f4,c0aede,d1d4f9&seed=${username}`,
82+
)
7983
avatarUrls.push(`https://api.dicebear.com/9.x/initials/svg?backgroundColor=b6e3f4,c0aede,d1d4f9&seed=${username}`)
8084
}
8185

8286
// Remove any duplicates and sort the avatarUrls
8387
return Array.from(new Set(avatarUrls))
8488
}
89+
90+
async getOnboardingRequirements(userId: string): Promise<OnboardingRequirements> {
91+
const user = await this.core.ensureUserById(userId)
92+
93+
const [usernames, avatarUrls] = await Promise.all([
94+
this.getOnboardingUsernames(userId),
95+
this.getOnboardingAvatarUrls(userId),
96+
])
97+
98+
const socialIdentities = user.identities.filter((i) => identityProvidersSocial.includes(i?.provider))
99+
const solanaIdentities = user.identities.filter((i) => i?.provider === IdentityProvider.Solana)
100+
const validAvatarUrl = avatarUrls.includes(user.avatarUrl ?? '')
101+
const validUsername = usernames.includes(user.username)
102+
const profileAccount = user.profile ?? null
103+
104+
let step: OnboardingStep
105+
106+
if (!socialIdentities.length) {
107+
step = OnboardingStep.LinkSocialIdentities
108+
} else if (!solanaIdentities.length) {
109+
step = OnboardingStep.LinkSolanaWallets
110+
} else if (!validAvatarUrl || !validUsername) {
111+
step = OnboardingStep.CustomizeProfile
112+
} else if (!user.profile) {
113+
step = OnboardingStep.CreateProfile
114+
} else {
115+
step = OnboardingStep.Finished
116+
}
117+
118+
return {
119+
profileAccount,
120+
socialIdentities: socialIdentities.length ?? 0,
121+
solanaIdentities: solanaIdentities.length ?? 0,
122+
validAvatarUrl: avatarUrls.includes(user.avatarUrl ?? ''),
123+
validUsername: usernames.includes(user.username),
124+
step,
125+
}
126+
}
127+
128+
async createProfile(userId: string, publicKey: string) {
129+
const user = await this.core.ensureUserById(userId)
130+
const requirementsMet = await this.getOnboardingRequirements(user.id)
131+
if (!requirementsMet) {
132+
throw new Error(`User ${user.id} has not met the onboarding requirements`)
133+
}
134+
135+
return this.protocol.createUserProfile(user.id, publicKey)
136+
}
137+
138+
async customizeProfile(userId: string, username: string, avatarUrl: string) {
139+
const user = await this.core.ensureUserById(userId)
140+
const [usernames, avatarUrls] = await Promise.all([
141+
this.getOnboardingUsernames(user.id),
142+
this.getOnboardingAvatarUrls(user.id),
143+
])
144+
145+
if (!usernames.includes(username)) {
146+
throw new Error(`User ${user.username} has not met the onboarding requirements`)
147+
}
148+
149+
if (!avatarUrls.includes(avatarUrl)) {
150+
throw new Error(`User ${user.username} has not met the onboarding requirements`)
151+
}
152+
153+
try {
154+
const updated = await this.core.updateUserById(user.id, { username, avatarUrl })
155+
this.logger.log(`User ${user.username} has been updated to ${updated.username} and ${updated.avatarUrl}`)
156+
return true
157+
} catch (error) {
158+
console.error(error)
159+
throw new Error(`User ${user.username} could not be updated`)
160+
}
161+
}
85162
}
86163

164+
const identityProvidersSocial: IdentityProvider[] = [
165+
IdentityProvider.Discord,
166+
IdentityProvider.Github,
167+
IdentityProvider.Google,
168+
IdentityProvider.Telegram,
169+
IdentityProvider.X,
170+
]
171+
87172
function cleanupUsernames(usernames: string[]) {
88173
return (
89174
usernames // Take the first part of any email addresses
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { registerEnumType } from '@nestjs/graphql'
2+
3+
export enum OnboardingStep {
4+
CreateProfile = 'CreateProfile',
5+
CustomizeProfile = 'CustomizeProfile',
6+
Finished = 'Finished',
7+
LinkSocialIdentities = 'LinkSocialIdentities',
8+
LinkSolanaWallets = 'LinkSolanaWallets',
9+
}
10+
11+
registerEnumType(OnboardingStep, { name: 'OnboardingStep' })
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Field, Int, ObjectType } from '@nestjs/graphql'
2+
import { OnboardingStep } from './onboarding-step'
3+
4+
@ObjectType()
5+
export class OnboardingRequirements {
6+
@Field(() => String, { nullable: true })
7+
profileAccount!: string | null
8+
@Field(() => Int, { nullable: true })
9+
socialIdentities!: number | null
10+
@Field(() => Int, { nullable: true })
11+
solanaIdentities!: number | null
12+
@Field(() => Boolean, { nullable: true })
13+
validUsername!: boolean
14+
@Field(() => Boolean, { nullable: true })
15+
validAvatarUrl!: boolean
16+
@Field(() => OnboardingStep)
17+
step!: OnboardingStep
18+
}

0 commit comments

Comments
 (0)