@nuxt-customer-portal/clients 0.3.3 → 0.4.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.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -0
- package/app/components/ClientsClientDetail.vue +38 -16
- package/app/components/ClientsClientForm.vue +134 -21
- package/app/components/ClientsClientPicker.vue +7 -1
- package/app/components/ClientsPersonalAddress.vue +58 -0
- package/app/components/ClientsPersonalProfile.vue +46 -0
- package/app/components/ClientsPrivateInvitation.vue +110 -0
- package/app/composables/useClients.ts +21 -0
- package/app/middleware/personal-onboarding.global.ts +14 -0
- package/app/pages/clients/[id].vue +7 -1
- package/app/pages/clients/index.vue +91 -8
- package/app/pages/personal-onboarding.vue +79 -0
- package/app/plugins/clients-feature.ts +10 -2
- package/i18n/locales/en.json +28 -2
- package/i18n/locales/nl.json +28 -2
- package/migrations/0001_person_clients.sql +37 -0
- package/migrations/0002_person_names.sql +18 -0
- package/nuxt.config.ts +7 -1
- package/package.json +3 -3
- package/portal.manifest.mjs +1 -1
- package/server/api/admin/private-client-invitations/index.get.ts +27 -0
- package/server/api/admin/private-client-invitations/index.post.ts +137 -0
- package/server/api/client-account.get.ts +12 -0
- package/server/api/clients/[id]/index.patch.ts +5 -1
- package/server/api/clients/[id]/invitations/[invitationId]/resend.post.ts +78 -0
- package/server/api/clients/[id]/invitations/[invitationId].patch.ts +2 -2
- package/server/api/clients/[id]/invitations/index.post.ts +35 -12
- package/server/api/clients/[id]/members/[memberId].delete.ts +2 -2
- package/server/api/clients/[id]/members/[memberId].patch.ts +6 -2
- package/server/api/clients/index.post.ts +5 -1
- package/server/api/personal-client/address.get.ts +15 -0
- package/server/api/personal-client/address.patch.ts +32 -0
- package/server/api/personal-client.get.ts +43 -0
- package/server/api/personal-client.post.ts +77 -0
- package/server/db/schema/clients.ts +29 -3
- package/server/middleware/00-client-configuration.ts +5 -0
- package/server/plugins/client-accounts.ts +114 -0
- package/server/utils/client-access.ts +9 -0
- package/server/utils/client-configuration.ts +37 -0
- package/server/utils/client-repository.ts +132 -63
- package/server/utils/client-validation.ts +33 -5
- package/server/utils/purchase-client.ts +90 -0
- package/shared/feature.ts +16 -0
- package/shared/personal-onboarding.ts +6 -0
- package/shared/private-client-invitation.ts +11 -0
- package/shared/types/client.ts +5 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { privateClientInvitationSchema } from '../../../../shared/private-client-invitation'
|
|
2
|
+
import { and, eq, gt, sql } from 'drizzle-orm'
|
|
3
|
+
import { db, requireSession } from '@nuxt-customer-portal/core/server/portal'
|
|
4
|
+
import { invitation, member, user } from '@nuxt-customer-portal/core/schema'
|
|
5
|
+
import { sendEmail } from '@nuxt-customer-portal/core/server/utils/email'
|
|
6
|
+
import { getPersonalAccountInvitationEmailContent } from '@nuxt-customer-portal/core/server/utils/email-texts'
|
|
7
|
+
import { clientProfile } from '../../../db/schema/clients'
|
|
8
|
+
import { getClientConfiguration } from '../../../utils/client-configuration'
|
|
9
|
+
import { createClientInTransaction, getClient } from '../../../utils/client-repository'
|
|
10
|
+
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
const session = await requireSession(event)
|
|
13
|
+
if (session.user.role !== 'admin') {
|
|
14
|
+
throw createError({ statusCode: 403 })
|
|
15
|
+
}
|
|
16
|
+
const parsed = privateClientInvitationSchema.safeParse(await readBody(event))
|
|
17
|
+
if (!parsed.success) {
|
|
18
|
+
throw createError({ statusCode: 400, message: 'Invalid invitation details' })
|
|
19
|
+
}
|
|
20
|
+
const input = parsed.data
|
|
21
|
+
const result = await db.transaction(async (tx) => {
|
|
22
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('portal-client-configuration'))`)
|
|
23
|
+
const config = await getClientConfiguration()
|
|
24
|
+
if (!config.allowedTypes.includes('person')) {
|
|
25
|
+
throw createError({ statusCode: 403, message: 'Private clients are disabled' })
|
|
26
|
+
}
|
|
27
|
+
const [retry] = await tx.select().from(invitation).where(eq(invitation.id, input.requestId))
|
|
28
|
+
if (retry) {
|
|
29
|
+
if (
|
|
30
|
+
retry.inviterId !== session.user.id ||
|
|
31
|
+
retry.email !== input.email ||
|
|
32
|
+
(input.clientId && retry.organizationId !== input.clientId)
|
|
33
|
+
) {
|
|
34
|
+
throw createError({ statusCode: 409, message: 'Invitation request already used' })
|
|
35
|
+
}
|
|
36
|
+
return { invitation: retry, created: false }
|
|
37
|
+
}
|
|
38
|
+
const [personalOwner] = await tx
|
|
39
|
+
.select({ id: member.id })
|
|
40
|
+
.from(user)
|
|
41
|
+
.innerJoin(member, eq(member.userId, user.id))
|
|
42
|
+
.innerJoin(clientProfile, eq(clientProfile.organizationId, member.organizationId))
|
|
43
|
+
.where(and(sql`lower(${user.email}) = ${input.email}`, eq(clientProfile.clientType, 'person')))
|
|
44
|
+
.limit(1)
|
|
45
|
+
if (personalOwner) {
|
|
46
|
+
throw createError({ statusCode: 409, message: 'This user already has a private client account' })
|
|
47
|
+
}
|
|
48
|
+
const [pendingForEmail] = await tx
|
|
49
|
+
.select({ id: invitation.id })
|
|
50
|
+
.from(invitation)
|
|
51
|
+
.innerJoin(clientProfile, eq(clientProfile.organizationId, invitation.organizationId))
|
|
52
|
+
.where(
|
|
53
|
+
and(
|
|
54
|
+
sql`lower(${invitation.email}) = ${input.email}`,
|
|
55
|
+
eq(invitation.status, 'pending'),
|
|
56
|
+
gt(invitation.expiresAt, new Date()),
|
|
57
|
+
eq(clientProfile.clientType, 'person')
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
.limit(1)
|
|
61
|
+
if (pendingForEmail) {
|
|
62
|
+
throw createError({
|
|
63
|
+
statusCode: 409,
|
|
64
|
+
message:
|
|
65
|
+
'A private-client invitation is already pending for this email. Revoke it from Pending invitations before inviting the existing private client again.'
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
let clientId = input.clientId
|
|
69
|
+
if (clientId) {
|
|
70
|
+
const [profile] = await tx.select().from(clientProfile).where(eq(clientProfile.organizationId, clientId))
|
|
71
|
+
const [linked] = await tx
|
|
72
|
+
.select({ id: member.id })
|
|
73
|
+
.from(member)
|
|
74
|
+
.where(eq(member.organizationId, clientId))
|
|
75
|
+
.limit(1)
|
|
76
|
+
const [pending] = await tx
|
|
77
|
+
.select({ id: invitation.id })
|
|
78
|
+
.from(invitation)
|
|
79
|
+
.where(
|
|
80
|
+
and(
|
|
81
|
+
eq(invitation.organizationId, clientId),
|
|
82
|
+
eq(invitation.status, 'pending'),
|
|
83
|
+
gt(invitation.expiresAt, new Date())
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
.limit(1)
|
|
87
|
+
if (!profile || profile.clientType !== 'person' || profile.archivedAt || linked || pending) {
|
|
88
|
+
throw createError({
|
|
89
|
+
statusCode: 409,
|
|
90
|
+
message: 'Choose an active private client without a linked user or pending invitation'
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
const [firstName, ...lastNameParts] = input.name!.trim().split(/\s+/)
|
|
95
|
+
clientId = await createClientInTransaction(tx, session.user.id, {
|
|
96
|
+
clientType: 'person',
|
|
97
|
+
name: input.name!,
|
|
98
|
+
firstName: firstName!,
|
|
99
|
+
lastName: lastNameParts.join(' ') || firstName!,
|
|
100
|
+
invoiceEmail: input.email,
|
|
101
|
+
address: '',
|
|
102
|
+
preferredLocale: input.preferredLocale,
|
|
103
|
+
timezone: null,
|
|
104
|
+
moduleIds: config.defaultModules ?? []
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
const [created] = await tx
|
|
108
|
+
.insert(invitation)
|
|
109
|
+
.values({
|
|
110
|
+
id: input.requestId,
|
|
111
|
+
organizationId: clientId,
|
|
112
|
+
email: input.email,
|
|
113
|
+
inviterId: session.user.id,
|
|
114
|
+
role: 'owner',
|
|
115
|
+
status: 'pending',
|
|
116
|
+
expiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
|
|
117
|
+
})
|
|
118
|
+
.returning()
|
|
119
|
+
return { invitation: created!, created: true }
|
|
120
|
+
})
|
|
121
|
+
if (result.created) {
|
|
122
|
+
const client = await getClient(result.invitation.organizationId)
|
|
123
|
+
const baseURL = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3000'
|
|
124
|
+
try {
|
|
125
|
+
await sendEmail({
|
|
126
|
+
to: input.email,
|
|
127
|
+
...getPersonalAccountInvitationEmailContent({
|
|
128
|
+
recipientName: client?.firstName || client?.name,
|
|
129
|
+
invitationLink: `${baseURL}/signup?invitationId=${result.invitation.id}`
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
} catch {
|
|
133
|
+
return { invitationId: result.invitation.id, deliveryFailed: true }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { invitationId: result.invitation.id, deliveryFailed: false }
|
|
137
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm'
|
|
2
|
+
import { requireSession, db } from '@nuxt-customer-portal/core/server/portal'
|
|
3
|
+
import { member } from '@nuxt-customer-portal/core/schema'
|
|
4
|
+
import { clientProfile } from '../db/schema/clients'
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const session = await requireSession(event)
|
|
8
|
+
return db
|
|
9
|
+
.select({ organizationId: clientProfile.organizationId, clientType: clientProfile.clientType })
|
|
10
|
+
.from(clientProfile)
|
|
11
|
+
.innerJoin(member, and(eq(member.organizationId, clientProfile.organizationId), eq(member.userId, session.user.id)))
|
|
12
|
+
})
|
|
@@ -5,6 +5,10 @@ import { clientUpdateSchema } from '@nuxt-customer-portal/clients/server/utils/c
|
|
|
5
5
|
export default defineEventHandler(async (event) => {
|
|
6
6
|
const id = getRouterParam(event, 'id')!
|
|
7
7
|
await requireClientProfileManager(event, id)
|
|
8
|
-
|
|
8
|
+
const parsed = clientUpdateSchema.safeParse(await readBody(event))
|
|
9
|
+
if (!parsed.success) {
|
|
10
|
+
throw createError({ statusCode: 400, message: 'Invalid client details', data: { issues: parsed.error.issues } })
|
|
11
|
+
}
|
|
12
|
+
await updateClient(id, parsed.data)
|
|
9
13
|
return getClient(id)
|
|
10
14
|
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm'
|
|
2
|
+
import { db } from '@nuxt-customer-portal/core/server/portal'
|
|
3
|
+
import { invitation, organization, user } from '@nuxt-customer-portal/core/schema'
|
|
4
|
+
import { sendEmail } from '@nuxt-customer-portal/core/server/utils/email'
|
|
5
|
+
import {
|
|
6
|
+
getInvitationEmailContent,
|
|
7
|
+
getPersonalAccountInvitationEmailContent
|
|
8
|
+
} from '@nuxt-customer-portal/core/server/utils/email-texts'
|
|
9
|
+
import { requireClientMemberManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
10
|
+
import { clientProfile } from '@nuxt-customer-portal/clients/server/db/schema/clients'
|
|
11
|
+
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
const organizationId = getRouterParam(event, 'id')!
|
|
14
|
+
const invitationId = getRouterParam(event, 'invitationId')!
|
|
15
|
+
await requireClientMemberManager(event, organizationId)
|
|
16
|
+
|
|
17
|
+
const [pending] = await db
|
|
18
|
+
.select({
|
|
19
|
+
id: invitation.id,
|
|
20
|
+
email: invitation.email,
|
|
21
|
+
role: invitation.role,
|
|
22
|
+
organizationName: organization.name,
|
|
23
|
+
inviterName: user.name,
|
|
24
|
+
inviterEmail: user.email,
|
|
25
|
+
clientType: clientProfile.clientType,
|
|
26
|
+
firstName: clientProfile.firstName,
|
|
27
|
+
preferredLocale: clientProfile.preferredLocale
|
|
28
|
+
})
|
|
29
|
+
.from(invitation)
|
|
30
|
+
.innerJoin(organization, eq(organization.id, invitation.organizationId))
|
|
31
|
+
.innerJoin(user, eq(user.id, invitation.inviterId))
|
|
32
|
+
.leftJoin(clientProfile, eq(clientProfile.organizationId, invitation.organizationId))
|
|
33
|
+
.where(
|
|
34
|
+
and(
|
|
35
|
+
eq(invitation.id, invitationId),
|
|
36
|
+
eq(invitation.organizationId, organizationId),
|
|
37
|
+
eq(invitation.status, 'pending')
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
.limit(1)
|
|
41
|
+
|
|
42
|
+
if (!pending) {
|
|
43
|
+
throw createError({ statusCode: 409, message: 'Invitation is no longer pending' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
|
|
47
|
+
await db
|
|
48
|
+
.update(invitation)
|
|
49
|
+
.set({ expiresAt })
|
|
50
|
+
.where(
|
|
51
|
+
and(
|
|
52
|
+
eq(invitation.id, invitationId),
|
|
53
|
+
eq(invitation.organizationId, organizationId),
|
|
54
|
+
eq(invitation.status, 'pending')
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const baseURL = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3000'
|
|
59
|
+
const invitationLink = `${baseURL}/signup?invitationId=${invitationId}`
|
|
60
|
+
await sendEmail({
|
|
61
|
+
to: pending.email,
|
|
62
|
+
locale: pending.preferredLocale ?? undefined,
|
|
63
|
+
...(pending.clientType === 'person'
|
|
64
|
+
? getPersonalAccountInvitationEmailContent({
|
|
65
|
+
invitationLink,
|
|
66
|
+
recipientName: pending.firstName || pending.organizationName
|
|
67
|
+
})
|
|
68
|
+
: getInvitationEmailContent({
|
|
69
|
+
inviterName: pending.inviterName || '',
|
|
70
|
+
inviterEmail: pending.inviterEmail,
|
|
71
|
+
organizationName: pending.organizationName,
|
|
72
|
+
role: pending.role || 'member',
|
|
73
|
+
invitationLink
|
|
74
|
+
}))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return { success: true, expiresAt }
|
|
78
|
+
})
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { changePendingInvitation } from '@nuxt-customer-portal/core/server/utils/invitation-management'
|
|
2
|
-
import {
|
|
2
|
+
import { requireClientMemberManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
const organizationId = getRouterParam(event, 'id')!
|
|
6
|
-
await
|
|
6
|
+
await requireClientMemberManager(event, organizationId)
|
|
7
7
|
return changePendingInvitation(organizationId, getRouterParam(event, 'invitationId')!, await readBody(event))
|
|
8
8
|
})
|
|
@@ -1,20 +1,37 @@
|
|
|
1
|
+
import { isPersonalClient } from '@nuxt-customer-portal/core/server/utils/client-account-policy'
|
|
1
2
|
import { and, eq } from 'drizzle-orm'
|
|
2
3
|
import { db } from '@nuxt-customer-portal/core/server/portal'
|
|
3
4
|
import { generateId } from '@nuxt-customer-portal/core/server/utils/auth'
|
|
4
|
-
import { invitation, organization } from '@nuxt-customer-portal/core/schema'
|
|
5
|
+
import { invitation, organization, member } from '@nuxt-customer-portal/core/schema'
|
|
5
6
|
import { sendEmail } from '@nuxt-customer-portal/core/server/utils/email'
|
|
6
|
-
import {
|
|
7
|
-
|
|
7
|
+
import {
|
|
8
|
+
getInvitationEmailContent,
|
|
9
|
+
getPersonalAccountInvitationEmailContent
|
|
10
|
+
} from '@nuxt-customer-portal/core/server/utils/email-texts'
|
|
11
|
+
import { requireClientMemberManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
8
12
|
import { genericClientInvitationSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
|
|
13
|
+
import { clientProfile } from '@nuxt-customer-portal/clients/server/db/schema/clients'
|
|
9
14
|
|
|
10
15
|
export default defineEventHandler(async (event) => {
|
|
11
16
|
const organizationId = getRouterParam(event, 'id')!
|
|
12
|
-
const context = await
|
|
17
|
+
const context = await requireClientMemberManager(event, organizationId)
|
|
13
18
|
const parsedInput = genericClientInvitationSchema.safeParse(await readBody(event))
|
|
14
19
|
if (!parsedInput.success) {
|
|
15
20
|
throw createError({ statusCode: 400, message: 'A valid email address and role are required' })
|
|
16
21
|
}
|
|
17
22
|
const input = parsedInput.data
|
|
23
|
+
const personalClient = await isPersonalClient(organizationId)
|
|
24
|
+
if (personalClient) {
|
|
25
|
+
input.role = 'owner'
|
|
26
|
+
const existing = await db
|
|
27
|
+
.select({ id: member.id })
|
|
28
|
+
.from(member)
|
|
29
|
+
.where(eq(member.organizationId, organizationId))
|
|
30
|
+
.limit(1)
|
|
31
|
+
if (existing.length) {
|
|
32
|
+
throw createError({ statusCode: 409, message: 'Personal client already has an account' })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
18
35
|
const [pending] = await db
|
|
19
36
|
.select({ id: invitation.id })
|
|
20
37
|
.from(invitation)
|
|
@@ -30,8 +47,9 @@ export default defineEventHandler(async (event) => {
|
|
|
30
47
|
throw createError({ statusCode: 409, message: 'A pending invitation already exists' })
|
|
31
48
|
}
|
|
32
49
|
const [client] = await db
|
|
33
|
-
.select({ name: organization.name })
|
|
50
|
+
.select({ name: organization.name, firstName: clientProfile.firstName })
|
|
34
51
|
.from(organization)
|
|
52
|
+
.leftJoin(clientProfile, eq(clientProfile.organizationId, organization.id))
|
|
35
53
|
.where(and(eq(organization.id, organizationId), eq(organization.organizationType, 'CLIENT')))
|
|
36
54
|
.limit(1)
|
|
37
55
|
if (!client) {
|
|
@@ -54,13 +72,18 @@ export default defineEventHandler(async (event) => {
|
|
|
54
72
|
const baseURL = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3000'
|
|
55
73
|
await sendEmail({
|
|
56
74
|
to: input.email,
|
|
57
|
-
...
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
...(personalClient
|
|
76
|
+
? getPersonalAccountInvitationEmailContent({
|
|
77
|
+
recipientName: client.firstName || client.name,
|
|
78
|
+
invitationLink: `${baseURL}/signup?invitationId=${id}`
|
|
79
|
+
})
|
|
80
|
+
: getInvitationEmailContent({
|
|
81
|
+
inviterName: context.session.user.name || '',
|
|
82
|
+
inviterEmail: context.session.user.email ?? '',
|
|
83
|
+
organizationName: client.name,
|
|
84
|
+
role: input.role,
|
|
85
|
+
invitationLink: `${baseURL}/signup?invitationId=${id}`
|
|
86
|
+
}))
|
|
64
87
|
})
|
|
65
88
|
return created
|
|
66
89
|
})
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { and, count, eq } from 'drizzle-orm'
|
|
2
2
|
import { db } from '@nuxt-customer-portal/core/server/portal'
|
|
3
3
|
import { member } from '@nuxt-customer-portal/core/schema'
|
|
4
|
-
import {
|
|
4
|
+
import { requireClientMemberManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
5
5
|
|
|
6
6
|
export default defineEventHandler(async (event) => {
|
|
7
7
|
const organizationId = getRouterParam(event, 'id')!
|
|
8
|
-
await
|
|
8
|
+
await requireClientMemberManager(event, organizationId)
|
|
9
9
|
const memberId = getRouterParam(event, 'memberId')!
|
|
10
10
|
const [selected] = await db
|
|
11
11
|
.select({ role: member.role })
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { isPersonalClient } from '@nuxt-customer-portal/core/server/utils/client-account-policy'
|
|
1
2
|
import { and, eq } from 'drizzle-orm'
|
|
2
3
|
import { db } from '@nuxt-customer-portal/core/server/portal'
|
|
3
4
|
import { member } from '@nuxt-customer-portal/core/schema'
|
|
4
|
-
import {
|
|
5
|
+
import { requireClientMemberManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
5
6
|
import { genericClientMemberUpdateSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
|
|
6
7
|
|
|
7
8
|
export default defineEventHandler(async (event) => {
|
|
8
9
|
const organizationId = getRouterParam(event, 'id')!
|
|
9
|
-
await
|
|
10
|
+
await requireClientMemberManager(event, organizationId)
|
|
11
|
+
if (await isPersonalClient(organizationId)) {
|
|
12
|
+
throw createError({ statusCode: 403, message: 'Personal membership roles cannot be changed' })
|
|
13
|
+
}
|
|
10
14
|
const input = genericClientMemberUpdateSchema.parse(await readBody(event))
|
|
11
15
|
const [updated] = await db
|
|
12
16
|
.update(member)
|
|
@@ -4,7 +4,11 @@ import { genericClientCreateSchema } from '@nuxt-customer-portal/clients/server/
|
|
|
4
4
|
|
|
5
5
|
export default defineEventHandler(async (event) => {
|
|
6
6
|
const { session } = await requireOwnerClientManager(event)
|
|
7
|
-
const
|
|
7
|
+
const parsed = genericClientCreateSchema.safeParse(await readBody(event))
|
|
8
|
+
if (!parsed.success) {
|
|
9
|
+
throw createError({ statusCode: 400, message: 'Invalid client details', data: { issues: parsed.error.issues } })
|
|
10
|
+
}
|
|
11
|
+
const input = parsed.data
|
|
8
12
|
const organizationId = await createClient(session.user.id, input)
|
|
9
13
|
return getClient(organizationId)
|
|
10
14
|
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm'
|
|
2
|
+
import { requireSession, db } from '@nuxt-customer-portal/core/server/portal'
|
|
3
|
+
import { member } from '@nuxt-customer-portal/core/schema'
|
|
4
|
+
import { clientProfile } from '../../db/schema/clients'
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const session = await requireSession(event)
|
|
8
|
+
const [client] = await db
|
|
9
|
+
.select({ address: clientProfile.address, archived: clientProfile.archivedAt })
|
|
10
|
+
.from(clientProfile)
|
|
11
|
+
.innerJoin(member, eq(member.organizationId, clientProfile.organizationId))
|
|
12
|
+
.where(and(eq(member.userId, session.user.id), eq(clientProfile.clientType, 'person')))
|
|
13
|
+
.limit(1)
|
|
14
|
+
return client ? { address: client.address, archived: Boolean(client.archived) } : null
|
|
15
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
|
3
|
+
import { requireSession, db } from '@nuxt-customer-portal/core/server/portal'
|
|
4
|
+
import { member } from '@nuxt-customer-portal/core/schema'
|
|
5
|
+
import { clientProfile } from '../../db/schema/clients'
|
|
6
|
+
|
|
7
|
+
const schema = z.object({ address: z.string().trim().max(1000) }).strict()
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
const session = await requireSession(event)
|
|
10
|
+
const parsed = schema.safeParse(await readBody(event))
|
|
11
|
+
if (!parsed.success) {
|
|
12
|
+
throw createError({ statusCode: 400, message: 'Invalid address', data: { issues: parsed.error.issues } })
|
|
13
|
+
}
|
|
14
|
+
const [client] = await db
|
|
15
|
+
.update(clientProfile)
|
|
16
|
+
.set({ address: parsed.data.address, updatedAt: new Date() })
|
|
17
|
+
.where(
|
|
18
|
+
and(
|
|
19
|
+
eq(clientProfile.clientType, 'person'),
|
|
20
|
+
isNull(clientProfile.archivedAt),
|
|
21
|
+
inArray(
|
|
22
|
+
clientProfile.organizationId,
|
|
23
|
+
db.select({ id: member.organizationId }).from(member).where(eq(member.userId, session.user.id))
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
.returning({ address: clientProfile.address })
|
|
28
|
+
if (!client) {
|
|
29
|
+
throw createError({ statusCode: 404, message: 'No active personal client account' })
|
|
30
|
+
}
|
|
31
|
+
return { address: client.address, archived: false }
|
|
32
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { and, eq, gt, sql } from 'drizzle-orm'
|
|
2
|
+
import { requireSession, db } from '@nuxt-customer-portal/core/server/portal'
|
|
3
|
+
import { user, member, invitation } from '@nuxt-customer-portal/core/schema'
|
|
4
|
+
import { getClientConfiguration } from '../utils/client-configuration'
|
|
5
|
+
import { needsPersonalOnboarding } from '../../shared/personal-onboarding'
|
|
6
|
+
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
const session = await requireSession(event)
|
|
9
|
+
const config = await getClientConfiguration()
|
|
10
|
+
if (!config.personalSelfRegistration) {
|
|
11
|
+
return { onboardingRequired: false }
|
|
12
|
+
}
|
|
13
|
+
const [account] = await db
|
|
14
|
+
.select({ email: user.email, emailVerified: user.emailVerified })
|
|
15
|
+
.from(user)
|
|
16
|
+
.where(eq(user.id, session.user.id))
|
|
17
|
+
.limit(1)
|
|
18
|
+
if (!account) {
|
|
19
|
+
throw createError({ statusCode: 401 })
|
|
20
|
+
}
|
|
21
|
+
const [memberships, invitations] = await Promise.all([
|
|
22
|
+
db.select({ id: member.id }).from(member).where(eq(member.userId, session.user.id)).limit(1),
|
|
23
|
+
db
|
|
24
|
+
.select({ id: invitation.id })
|
|
25
|
+
.from(invitation)
|
|
26
|
+
.where(
|
|
27
|
+
and(
|
|
28
|
+
sql`lower(${invitation.email}) = lower(${account.email})`,
|
|
29
|
+
eq(invitation.status, 'pending'),
|
|
30
|
+
gt(invitation.expiresAt, new Date())
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
.limit(1)
|
|
34
|
+
])
|
|
35
|
+
return {
|
|
36
|
+
onboardingRequired: needsPersonalOnboarding({
|
|
37
|
+
enabled: true,
|
|
38
|
+
emailVerified: account.emailVerified,
|
|
39
|
+
membershipCount: memberships.length,
|
|
40
|
+
hasPendingInvitation: invitations.length > 0
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { eq, and, sql } from 'drizzle-orm'
|
|
3
|
+
import { nanoid } from 'nanoid'
|
|
4
|
+
import { requireSession, db } from '@nuxt-customer-portal/core/server/portal'
|
|
5
|
+
import { member, user } from '@nuxt-customer-portal/core/schema'
|
|
6
|
+
import { timezoneSchema } from '@nuxt-customer-portal/core/server/utils/timezone-validation'
|
|
7
|
+
import { clientProfile } from '../db/schema/clients'
|
|
8
|
+
import { getClientConfiguration } from '../utils/client-configuration'
|
|
9
|
+
import { createClientInTransaction, getClient } from '../utils/client-repository'
|
|
10
|
+
|
|
11
|
+
const schema = z.object({
|
|
12
|
+
firstName: z.string().trim().min(1).max(80),
|
|
13
|
+
lastName: z.string().trim().min(1).max(80),
|
|
14
|
+
preferredLocale: z.enum(['nl', 'en']),
|
|
15
|
+
timezone: timezoneSchema.nullable()
|
|
16
|
+
})
|
|
17
|
+
export default defineEventHandler(async (event) => {
|
|
18
|
+
const session = await requireSession(event)
|
|
19
|
+
const config = await getClientConfiguration()
|
|
20
|
+
if (!config.personalSelfRegistration || !config.allowedTypes.includes('person')) {
|
|
21
|
+
throw createError({ statusCode: 403, message: 'Personal registration is disabled' })
|
|
22
|
+
}
|
|
23
|
+
const parsed = schema.safeParse(await readBody(event))
|
|
24
|
+
if (!parsed.success) {
|
|
25
|
+
throw createError({ statusCode: 400, message: 'Invalid client details', data: { issues: parsed.error.issues } })
|
|
26
|
+
}
|
|
27
|
+
const input = parsed.data
|
|
28
|
+
const id = await db
|
|
29
|
+
.transaction(async (tx) => {
|
|
30
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('portal-client-configuration'))`)
|
|
31
|
+
if (!(await getClientConfiguration()).personalSelfRegistration) {
|
|
32
|
+
throw createError({ statusCode: 403, message: 'Personal registration is disabled' })
|
|
33
|
+
}
|
|
34
|
+
// Per-user lock makes retries and concurrent onboarding idempotent.
|
|
35
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${session.user.id}))`)
|
|
36
|
+
const [account] = await tx.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
|
37
|
+
if (!account?.emailVerified) {
|
|
38
|
+
throw createError({ statusCode: 403, message: 'Verify your email first' })
|
|
39
|
+
}
|
|
40
|
+
const [existing] = await tx
|
|
41
|
+
.select({ id: member.organizationId })
|
|
42
|
+
.from(member)
|
|
43
|
+
.innerJoin(clientProfile, eq(clientProfile.organizationId, member.organizationId))
|
|
44
|
+
.where(and(eq(member.userId, account.id), eq(clientProfile.clientType, 'person')))
|
|
45
|
+
.limit(1)
|
|
46
|
+
if (existing) {
|
|
47
|
+
return existing.id
|
|
48
|
+
}
|
|
49
|
+
const id = await createClientInTransaction(tx, account.id, {
|
|
50
|
+
...input,
|
|
51
|
+
name: account.name || `${input.firstName} ${input.lastName}`,
|
|
52
|
+
clientType: 'person',
|
|
53
|
+
address: '',
|
|
54
|
+
invoiceEmail: account.email,
|
|
55
|
+
moduleIds: config.defaultModules ?? []
|
|
56
|
+
})
|
|
57
|
+
await tx
|
|
58
|
+
.insert(member)
|
|
59
|
+
.values({ id: nanoid(), organizationId: id, userId: account.id, role: 'owner', createdAt: new Date() })
|
|
60
|
+
await tx
|
|
61
|
+
.update(user)
|
|
62
|
+
.set({
|
|
63
|
+
timezone: input.timezone,
|
|
64
|
+
firstName: input.firstName,
|
|
65
|
+
lastName: input.lastName
|
|
66
|
+
})
|
|
67
|
+
.where(eq(user.id, account.id))
|
|
68
|
+
return id
|
|
69
|
+
})
|
|
70
|
+
.catch((error: { code?: string; cause?: { code?: string } }) => {
|
|
71
|
+
if (error.code === '23505' || error.cause?.code === '23505') {
|
|
72
|
+
throw createError({ statusCode: 409, message: 'Personal account already exists. Contact your coach.' })
|
|
73
|
+
}
|
|
74
|
+
throw error
|
|
75
|
+
})
|
|
76
|
+
return getClient(id)
|
|
77
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
import { boolean, check, index, pgSchema, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
|
|
3
|
+
import { organization, user, member } from '@nuxt-customer-portal/core/schema'
|
|
3
4
|
|
|
4
5
|
export const clientsSchema = pgSchema('clients')
|
|
5
6
|
|
|
@@ -17,6 +18,10 @@ export const clientProfile = clientsSchema.table(
|
|
|
17
18
|
organizationId: text('organization_id')
|
|
18
19
|
.primaryKey()
|
|
19
20
|
.references(() => organization.id, { onDelete: 'restrict' }),
|
|
21
|
+
clientType: text('client_type').$type<'organization' | 'person'>().default('organization').notNull(),
|
|
22
|
+
firstName: text('first_name'),
|
|
23
|
+
lastName: text('last_name'),
|
|
24
|
+
timezone: text('timezone'),
|
|
20
25
|
officialName: text('official_name').notNull(),
|
|
21
26
|
address: text('address').default('').notNull(),
|
|
22
27
|
registrationNumber: text('registration_number'),
|
|
@@ -27,7 +32,14 @@ export const clientProfile = clientsSchema.table(
|
|
|
27
32
|
archivedById: text('archived_by_id').references(() => user.id, { onDelete: 'set null' }),
|
|
28
33
|
...auditColumns
|
|
29
34
|
},
|
|
30
|
-
(table) => [
|
|
35
|
+
(table) => [
|
|
36
|
+
index('client_profile_archived_idx').on(table.archivedAt),
|
|
37
|
+
check('client_profile_client_type_check', sql`${table.clientType} IN ('organization', 'person')`),
|
|
38
|
+
check(
|
|
39
|
+
'person_no_company_fields',
|
|
40
|
+
sql`${table.clientType} <> 'person' OR (${table.registrationNumber} IS NULL AND ${table.vatNumber} IS NULL)`
|
|
41
|
+
)
|
|
42
|
+
]
|
|
31
43
|
)
|
|
32
44
|
|
|
33
45
|
export const clientModule = clientsSchema.table(
|
|
@@ -53,3 +65,17 @@ export const clientModule = clientsSchema.table(
|
|
|
53
65
|
|
|
54
66
|
export type ClientProfileRecord = typeof clientProfile.$inferSelect
|
|
55
67
|
export type ClientModuleRecord = typeof clientModule.$inferSelect
|
|
68
|
+
|
|
69
|
+
export const personalMembership = clientsSchema.table('personal_membership', {
|
|
70
|
+
organizationId: text('organization_id')
|
|
71
|
+
.primaryKey()
|
|
72
|
+
.references(() => clientProfile.organizationId, { onDelete: 'cascade' }),
|
|
73
|
+
userId: text('user_id')
|
|
74
|
+
.notNull()
|
|
75
|
+
.unique()
|
|
76
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
77
|
+
memberId: text('member_id')
|
|
78
|
+
.notNull()
|
|
79
|
+
.unique()
|
|
80
|
+
.references(() => member.id, { onDelete: 'cascade' })
|
|
81
|
+
})
|