@nuxt-customer-portal/clients 0.3.3 → 0.4.1
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 +44 -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,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerUserDisplayNameChangedHook,
|
|
3
|
+
registerUserIdentityChangedHook
|
|
4
|
+
} from '@nuxt-customer-portal/core/server/utils/business-hooks'
|
|
5
|
+
import { and, eq, inArray } from 'drizzle-orm'
|
|
6
|
+
import { createError } from 'h3'
|
|
7
|
+
import { db } from '@nuxt-customer-portal/core/server/utils/db'
|
|
8
|
+
import { member, organization } from '@nuxt-customer-portal/core/schema'
|
|
9
|
+
import { registerClientAccountPolicy } from '@nuxt-customer-portal/core/server/utils/client-account-policy'
|
|
10
|
+
import { registerClientTimezoneResolver } from '@nuxt-customer-portal/core/server/utils/timezones'
|
|
11
|
+
import { clientProfile } from '../db/schema/clients'
|
|
12
|
+
import { getClientConfiguration, setClientConfigurationValidation } from '../utils/client-configuration'
|
|
13
|
+
|
|
14
|
+
export default defineNitroPlugin(() => {
|
|
15
|
+
registerUserDisplayNameChangedHook(async (transaction, userId, name) => {
|
|
16
|
+
const tx = transaction as Parameters<Parameters<typeof db.transaction>[0]>[0]
|
|
17
|
+
const personalClients = await tx
|
|
18
|
+
.select({ id: clientProfile.organizationId })
|
|
19
|
+
.from(clientProfile)
|
|
20
|
+
.innerJoin(member, eq(member.organizationId, clientProfile.organizationId))
|
|
21
|
+
.where(and(eq(member.userId, userId), eq(clientProfile.clientType, 'person')))
|
|
22
|
+
for (const client of personalClients) {
|
|
23
|
+
await tx.update(organization).set({ name }).where(eq(organization.id, client.id))
|
|
24
|
+
await tx
|
|
25
|
+
.update(clientProfile)
|
|
26
|
+
.set({ officialName: name, updatedAt: new Date() })
|
|
27
|
+
.where(eq(clientProfile.organizationId, client.id))
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
registerUserIdentityChangedHook(async (transaction, userId, identity) => {
|
|
31
|
+
if (!identity.firstName || !identity.lastName) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const tx = transaction as Parameters<Parameters<typeof db.transaction>[0]>[0]
|
|
35
|
+
const personalClientIds = tx
|
|
36
|
+
.select({ id: member.organizationId })
|
|
37
|
+
.from(member)
|
|
38
|
+
.innerJoin(clientProfile, eq(clientProfile.organizationId, member.organizationId))
|
|
39
|
+
.where(and(eq(member.userId, userId), eq(clientProfile.clientType, 'person')))
|
|
40
|
+
await tx
|
|
41
|
+
.update(clientProfile)
|
|
42
|
+
.set({ firstName: identity.firstName, lastName: identity.lastName, updatedAt: new Date() })
|
|
43
|
+
.where(inArray(clientProfile.organizationId, personalClientIds))
|
|
44
|
+
})
|
|
45
|
+
// Nitro does not await plugin return values. Register policies synchronously,
|
|
46
|
+
// and gate requests on database validation instead of leaving a startup gap.
|
|
47
|
+
const configurationReady = Promise.resolve()
|
|
48
|
+
.then(async () => {
|
|
49
|
+
const config = await getClientConfiguration()
|
|
50
|
+
return db
|
|
51
|
+
.selectDistinct({ clientType: clientProfile.clientType })
|
|
52
|
+
.from(clientProfile)
|
|
53
|
+
.then((profiles) =>
|
|
54
|
+
profiles.some((row) => !config.allowedTypes.includes(row.clientType))
|
|
55
|
+
? 'Cannot disable a client type while clients of that type exist'
|
|
56
|
+
: null
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
.catch(() => 'Client configuration could not be validated. Check database migrations and connectivity.')
|
|
60
|
+
setClientConfigurationValidation(configurationReady)
|
|
61
|
+
const profile = async (id: string) =>
|
|
62
|
+
(await db.select().from(clientProfile).where(eq(clientProfile.organizationId, id)).limit(1))[0]
|
|
63
|
+
registerClientTimezoneResolver(async (id) => (await profile(id))?.timezone ?? null)
|
|
64
|
+
registerClientAccountPolicy({
|
|
65
|
+
isPersonal: async (id) => (await profile(id))?.clientType === 'person',
|
|
66
|
+
getIdentity: async (id) => {
|
|
67
|
+
const client = await profile(id)
|
|
68
|
+
if (!client) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name: client.officialName,
|
|
73
|
+
...(client.firstName ? { firstName: client.firstName } : {}),
|
|
74
|
+
...(client.lastName ? { lastName: client.lastName } : {})
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
assertAcceptance: async (id, userId) => {
|
|
78
|
+
const client = await profile(id)
|
|
79
|
+
if (client?.archivedAt) {
|
|
80
|
+
throw createError({ statusCode: 403, message: 'Archived clients cannot accept invitations' })
|
|
81
|
+
}
|
|
82
|
+
if (client?.clientType !== 'person') {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const existing = await db
|
|
86
|
+
.select({ organizationId: member.organizationId })
|
|
87
|
+
.from(member)
|
|
88
|
+
.where(eq(member.organizationId, id))
|
|
89
|
+
.limit(1)
|
|
90
|
+
const personal = await db
|
|
91
|
+
.select({ id: member.id })
|
|
92
|
+
.from(member)
|
|
93
|
+
.where(
|
|
94
|
+
and(
|
|
95
|
+
eq(member.userId, userId),
|
|
96
|
+
inArray(
|
|
97
|
+
member.organizationId,
|
|
98
|
+
db
|
|
99
|
+
.select({ id: clientProfile.organizationId })
|
|
100
|
+
.from(clientProfile)
|
|
101
|
+
.where(eq(clientProfile.clientType, 'person'))
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
.limit(1)
|
|
106
|
+
if (existing.length || personal.length) {
|
|
107
|
+
throw createError({
|
|
108
|
+
statusCode: 409,
|
|
109
|
+
message: 'A personal account already exists. Contact your coach to reconcile the accounts.'
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { H3Event } from 'h3'
|
|
2
|
+
import { isPersonalClient } from '@nuxt-customer-portal/core/server/utils/client-account-policy'
|
|
2
3
|
import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
|
|
3
4
|
|
|
4
5
|
export const requireOwnerClientManager = async (event: H3Event) => {
|
|
@@ -21,3 +22,11 @@ export const requireClientProfileManager = async (event: H3Event, clientOrganiza
|
|
|
21
22
|
}
|
|
22
23
|
return context
|
|
23
24
|
}
|
|
25
|
+
|
|
26
|
+
export const requireClientMemberManager = async (event: H3Event, id: string) => {
|
|
27
|
+
const context = await requireClientProfileManager(event, id)
|
|
28
|
+
if (context.organizationType !== 'PROVIDER' && (await isPersonalClient(id))) {
|
|
29
|
+
throw createError({ statusCode: 403, message: 'Personal accounts cannot manage members or invitations' })
|
|
30
|
+
}
|
|
31
|
+
return context
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { resolveClientConfigurationOverride } from '@nuxt-customer-portal/core/server/utils/client-configuration'
|
|
2
|
+
import { createError } from 'h3'
|
|
3
|
+
|
|
4
|
+
export const getClientConfiguration = async () => {
|
|
5
|
+
const config = useRuntimeConfig()
|
|
6
|
+
const clients = { ...config.public.clients, ...(await resolveClientConfigurationOverride()) } as {
|
|
7
|
+
allowedTypes?: string[]
|
|
8
|
+
personalSelfRegistration?: boolean
|
|
9
|
+
defaultModules?: string[]
|
|
10
|
+
}
|
|
11
|
+
const allowedTypes = clients.allowedTypes ?? ['organization']
|
|
12
|
+
if (!allowedTypes.length || allowedTypes.some((type) => !['organization', 'person'].includes(type))) {
|
|
13
|
+
throw new Error('clients.allowedTypes must contain organization and/or person')
|
|
14
|
+
}
|
|
15
|
+
const registrationMode = process.env.PORTAL_REGISTRATION_MODE || config.portalAuth.registrationMode
|
|
16
|
+
if (clients.personalSelfRegistration && (!allowedTypes.includes('person') || registrationMode !== 'open')) {
|
|
17
|
+
throw new Error('Personal registration requires person clients and open authentication registration')
|
|
18
|
+
}
|
|
19
|
+
return { ...clients, allowedTypes }
|
|
20
|
+
}
|
|
21
|
+
export const requireAllowedClientType = async (type: string) => {
|
|
22
|
+
if (!(await getClientConfiguration()).allowedTypes.includes(type)) {
|
|
23
|
+
throw createError({ statusCode: 403, message: 'This client type is not enabled' })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Server middleware awaits this promise; Nitro request hooks swallow thrown errors.
|
|
28
|
+
let configurationReady: Promise<string | null> = Promise.resolve('Client configuration has not initialized')
|
|
29
|
+
export const setClientConfigurationValidation = (ready: Promise<string | null>) => {
|
|
30
|
+
configurationReady = ready
|
|
31
|
+
}
|
|
32
|
+
export const requireClientConfigurationReady = async () => {
|
|
33
|
+
const error = await configurationReady
|
|
34
|
+
if (error) {
|
|
35
|
+
throw createError({ statusCode: 503, message: error })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, asc, count, desc, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm'
|
|
1
|
+
import { and, asc, count, desc, eq, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
|
|
2
2
|
import { nanoid } from 'nanoid'
|
|
3
3
|
import { getOrganizationAvatar } from '@nuxt-customer-portal/core/shared/organization-avatar'
|
|
4
4
|
import { db } from '@nuxt-customer-portal/core/server/portal'
|
|
@@ -8,6 +8,9 @@ import { runClientCreatedHooks } from '@nuxt-customer-portal/core/server/utils/b
|
|
|
8
8
|
import type { GenericClientDto, ClientListResponse } from '@nuxt-customer-portal/clients/shared/types/client'
|
|
9
9
|
import type { ClientCreateInput, GenericClientListQuery, ClientUpdateInput } from './client-validation'
|
|
10
10
|
|
|
11
|
+
import { getProviderTimezone } from '@nuxt-customer-portal/core/server/utils/timezones'
|
|
12
|
+
import { requireAllowedClientType } from './client-configuration'
|
|
13
|
+
|
|
11
14
|
const normalizeNullable = (value: string | null | undefined) => value?.trim() || null
|
|
12
15
|
|
|
13
16
|
const clientSelection = {
|
|
@@ -17,6 +20,10 @@ const clientSelection = {
|
|
|
17
20
|
logo: organization.logo,
|
|
18
21
|
metadata: organization.metadata,
|
|
19
22
|
createdAt: organization.createdAt,
|
|
23
|
+
clientType: clientProfile.clientType,
|
|
24
|
+
firstName: clientProfile.firstName,
|
|
25
|
+
lastName: clientProfile.lastName,
|
|
26
|
+
timezone: clientProfile.timezone,
|
|
20
27
|
officialName: clientProfile.officialName,
|
|
21
28
|
address: clientProfile.address,
|
|
22
29
|
registrationNumber: clientProfile.registrationNumber,
|
|
@@ -33,6 +40,10 @@ interface ClientRow {
|
|
|
33
40
|
logo: string | null
|
|
34
41
|
metadata: string | null
|
|
35
42
|
createdAt: Date
|
|
43
|
+
clientType: 'organization' | 'person'
|
|
44
|
+
firstName: string | null
|
|
45
|
+
lastName: string | null
|
|
46
|
+
timezone: string | null
|
|
36
47
|
officialName: string
|
|
37
48
|
address: string
|
|
38
49
|
registrationNumber: string | null
|
|
@@ -46,6 +57,7 @@ const hydrateClients = async (rows: ClientRow[]): Promise<GenericClientDto[]> =>
|
|
|
46
57
|
if (!rows.length) {
|
|
47
58
|
return []
|
|
48
59
|
}
|
|
60
|
+
const providerTimezone = await getProviderTimezone()
|
|
49
61
|
const ids = rows.map((row) => row.organizationId as string)
|
|
50
62
|
const [modules, members, invitations] = await Promise.all([
|
|
51
63
|
db.select().from(clientModule).where(inArray(clientModule.organizationId, ids)),
|
|
@@ -58,6 +70,8 @@ const hydrateClients = async (rows: ClientRow[]): Promise<GenericClientDto[]> =>
|
|
|
58
70
|
phone: member.phone,
|
|
59
71
|
jobTitle: member.jobTitle,
|
|
60
72
|
name: user.name,
|
|
73
|
+
firstName: user.firstName,
|
|
74
|
+
lastName: user.lastName,
|
|
61
75
|
email: user.email,
|
|
62
76
|
image: user.image
|
|
63
77
|
})
|
|
@@ -78,26 +92,32 @@ const hydrateClients = async (rows: ClientRow[]): Promise<GenericClientDto[]> =>
|
|
|
78
92
|
.where(and(inArray(invitation.organizationId, ids), eq(invitation.status, 'pending')))
|
|
79
93
|
.orderBy(asc(invitation.email))
|
|
80
94
|
])
|
|
81
|
-
return rows.map((row) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
95
|
+
return rows.map((row) => {
|
|
96
|
+
const clientMembers = members.filter((item) => item.organizationId === row.organizationId)
|
|
97
|
+
const personalMember = row.clientType === 'person' ? clientMembers[0] : undefined
|
|
98
|
+
return {
|
|
99
|
+
id: row.organizationId,
|
|
100
|
+
organizationId: row.organizationId,
|
|
101
|
+
name: row.name,
|
|
102
|
+
firstName: row.clientType === 'person' ? (row.firstName ?? personalMember?.firstName ?? null) : null,
|
|
103
|
+
lastName: row.clientType === 'person' ? (row.lastName ?? personalMember?.lastName ?? null) : null,
|
|
104
|
+
slug: row.slug,
|
|
105
|
+
logo: row.logo,
|
|
106
|
+
avatarLogo: getOrganizationAvatar(row) ?? null,
|
|
107
|
+
clientType: row.clientType,
|
|
108
|
+
timezone: row.timezone,
|
|
109
|
+
schedulingTimezone: row.timezone || providerTimezone,
|
|
110
|
+
officialName: row.officialName,
|
|
111
|
+
address: row.address,
|
|
112
|
+
registrationNumber: row.registrationNumber,
|
|
113
|
+
vatNumber: row.vatNumber,
|
|
114
|
+
invoiceEmail: row.invoiceEmail,
|
|
115
|
+
preferredLocale: row.preferredLocale === 'en' ? 'en' : 'nl',
|
|
116
|
+
archivedAt: row.archivedAt?.toISOString() ?? null,
|
|
117
|
+
modules: modules
|
|
118
|
+
.filter((item) => item.organizationId === row.organizationId)
|
|
119
|
+
.map((item) => ({ moduleId: item.moduleId, enabled: item.enabled })),
|
|
120
|
+
members: clientMembers.map((item) => ({
|
|
101
121
|
id: item.id,
|
|
102
122
|
userId: item.userId,
|
|
103
123
|
name: item.name,
|
|
@@ -107,20 +127,22 @@ const hydrateClients = async (rows: ClientRow[]): Promise<GenericClientDto[]> =>
|
|
|
107
127
|
phone: item.phone,
|
|
108
128
|
jobTitle: item.jobTitle
|
|
109
129
|
})),
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
invitations: invitations
|
|
131
|
+
.filter((item) => item.organizationId === row.organizationId)
|
|
132
|
+
.map((item) => ({
|
|
133
|
+
id: item.id,
|
|
134
|
+
email: item.email,
|
|
135
|
+
role: item.role || 'member',
|
|
136
|
+
status: item.status,
|
|
137
|
+
expiresAt: item.expiresAt.toISOString()
|
|
138
|
+
}))
|
|
139
|
+
}
|
|
140
|
+
})
|
|
120
141
|
}
|
|
121
142
|
|
|
122
143
|
export const listGenericClientsPage = async (query: GenericClientListQuery): Promise<ClientListResponse> => {
|
|
123
144
|
const conditions = [
|
|
145
|
+
query.clientType ? eq(clientProfile.clientType, query.clientType) : undefined,
|
|
124
146
|
eq(organization.organizationType, 'CLIENT'),
|
|
125
147
|
query.status === 'archived'
|
|
126
148
|
? isNotNull(clientProfile.archivedAt)
|
|
@@ -202,39 +224,59 @@ export const listSelectableClients = async (moduleId?: string) => {
|
|
|
202
224
|
return page.items
|
|
203
225
|
}
|
|
204
226
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
227
|
+
type ClientTransaction = Pick<typeof db, 'select' | 'insert' | 'execute'>
|
|
228
|
+
export const createClientInTransaction = async (
|
|
229
|
+
tx: ClientTransaction,
|
|
230
|
+
actorUserId: string,
|
|
231
|
+
input: ClientCreateInput
|
|
232
|
+
) => {
|
|
233
|
+
const type = input.clientType ?? 'organization'
|
|
234
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('portal-client-configuration'))`)
|
|
235
|
+
await requireAllowedClientType(type)
|
|
236
|
+
const clientSlug =
|
|
237
|
+
type === 'person'
|
|
238
|
+
? `person-${nanoid()
|
|
239
|
+
.toLowerCase()
|
|
240
|
+
.replace(/[^a-z0-9]/g, '')}`
|
|
241
|
+
: input.slug!
|
|
242
|
+
|
|
243
|
+
const [existingSlug] = await tx
|
|
244
|
+
.select({ id: organization.id })
|
|
245
|
+
.from(organization)
|
|
246
|
+
.where(eq(organization.slug, clientSlug))
|
|
247
|
+
.limit(1)
|
|
248
|
+
if (existingSlug) {
|
|
249
|
+
throw createError({ statusCode: 409, message: 'Organization slug already exists', data: { field: 'slug' } })
|
|
250
|
+
}
|
|
251
|
+
const organizationId = nanoid()
|
|
252
|
+
await tx.insert(organization).values({
|
|
253
|
+
id: organizationId,
|
|
254
|
+
name: input.name,
|
|
255
|
+
slug: clientSlug,
|
|
256
|
+
organizationType: 'CLIENT',
|
|
257
|
+
createdAt: new Date()
|
|
258
|
+
})
|
|
259
|
+
await tx.insert(clientProfile).values({
|
|
260
|
+
organizationId,
|
|
261
|
+
clientType: type,
|
|
262
|
+
firstName: type === 'person' ? input.firstName : null,
|
|
263
|
+
lastName: type === 'person' ? input.lastName : null,
|
|
264
|
+
timezone: input.timezone ?? null,
|
|
265
|
+
officialName: type === 'person' ? input.name : input.officialName!,
|
|
266
|
+
address: input.address,
|
|
267
|
+
registrationNumber: normalizeNullable(input.registrationNumber),
|
|
268
|
+
vatNumber: normalizeNullable(input.vatNumber),
|
|
269
|
+
invoiceEmail: normalizeNullable(input.invoiceEmail),
|
|
270
|
+
preferredLocale: input.preferredLocale
|
|
237
271
|
})
|
|
272
|
+
await runClientCreatedHooks(tx, organizationId)
|
|
273
|
+
for (const moduleId of [...new Set(input.moduleIds ?? [])]) {
|
|
274
|
+
await tx.insert(clientModule).values({ id: nanoid(), organizationId, moduleId, enabledById: actorUserId })
|
|
275
|
+
}
|
|
276
|
+
return organizationId
|
|
277
|
+
}
|
|
278
|
+
export const createClient = async (actorUserId: string, input: ClientCreateInput) =>
|
|
279
|
+
db.transaction((tx) => createClientInTransaction(tx, actorUserId, input))
|
|
238
280
|
|
|
239
281
|
export const updateClient = async (organizationId: string, input: ClientUpdateInput) =>
|
|
240
282
|
db.transaction(async (tx) => {
|
|
@@ -246,6 +288,13 @@ export const updateClient = async (organizationId: string, input: ClientUpdateIn
|
|
|
246
288
|
if (!selected) {
|
|
247
289
|
throw createError({ statusCode: 404, message: 'Client not found' })
|
|
248
290
|
}
|
|
291
|
+
const [profile] = await tx.select().from(clientProfile).where(eq(clientProfile.organizationId, organizationId))
|
|
292
|
+
if (profile?.clientType === 'person' && (input.registrationNumber || input.vatNumber)) {
|
|
293
|
+
throw createError({ statusCode: 400, message: 'Personal clients cannot have company fields' })
|
|
294
|
+
}
|
|
295
|
+
if (profile?.clientType !== 'person' && (input.firstName !== undefined || input.lastName !== undefined)) {
|
|
296
|
+
throw createError({ statusCode: 400, message: 'Name parts are only available for personal clients' })
|
|
297
|
+
}
|
|
249
298
|
const orgValues: Record<string, unknown> = {}
|
|
250
299
|
if (input.name !== undefined) {
|
|
251
300
|
orgValues.name = input.name
|
|
@@ -257,6 +306,12 @@ export const updateClient = async (organizationId: string, input: ClientUpdateIn
|
|
|
257
306
|
if (input.officialName !== undefined) {
|
|
258
307
|
values.officialName = input.officialName
|
|
259
308
|
}
|
|
309
|
+
if (input.timezone !== undefined) {
|
|
310
|
+
values.timezone = input.timezone
|
|
311
|
+
}
|
|
312
|
+
if (profile?.clientType === 'person' && input.name !== undefined) {
|
|
313
|
+
values.officialName = input.name
|
|
314
|
+
}
|
|
260
315
|
if (input.address !== undefined) {
|
|
261
316
|
values.address = input.address
|
|
262
317
|
}
|
|
@@ -272,7 +327,21 @@ export const updateClient = async (organizationId: string, input: ClientUpdateIn
|
|
|
272
327
|
if (input.preferredLocale !== undefined) {
|
|
273
328
|
values.preferredLocale = input.preferredLocale
|
|
274
329
|
}
|
|
330
|
+
if (input.firstName !== undefined && input.lastName !== undefined) {
|
|
331
|
+
values.firstName = input.firstName
|
|
332
|
+
values.lastName = input.lastName
|
|
333
|
+
}
|
|
275
334
|
await tx.update(clientProfile).set(values).where(eq(clientProfile.organizationId, organizationId))
|
|
335
|
+
if (input.firstName !== undefined && input.lastName !== undefined) {
|
|
336
|
+
const personalUserIds = tx
|
|
337
|
+
.select({ id: member.userId })
|
|
338
|
+
.from(member)
|
|
339
|
+
.where(eq(member.organizationId, organizationId))
|
|
340
|
+
await tx
|
|
341
|
+
.update(user)
|
|
342
|
+
.set({ firstName: input.firstName, lastName: input.lastName })
|
|
343
|
+
.where(inArray(user.id, personalUserIds))
|
|
344
|
+
}
|
|
276
345
|
})
|
|
277
346
|
|
|
278
347
|
export const setClientArchived = async (organizationId: string, actorUserId: string, archived: boolean) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { timezoneSchema } from '@nuxt-customer-portal/core/server/utils/timezone-validation'
|
|
2
3
|
|
|
3
4
|
const id = z.string().min(1).max(128)
|
|
4
5
|
const slug = z
|
|
@@ -9,6 +10,7 @@ const slug = z
|
|
|
9
10
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
|
10
11
|
|
|
11
12
|
export const genericClientListQuerySchema = z.object({
|
|
13
|
+
clientType: z.enum(['organization', 'person']).optional(),
|
|
12
14
|
search: z.string().trim().max(200).optional(),
|
|
13
15
|
status: z.enum(['all', 'active', 'archived']).optional(),
|
|
14
16
|
moduleId: z.string().trim().max(100).optional(),
|
|
@@ -18,10 +20,14 @@ export const genericClientListQuerySchema = z.object({
|
|
|
18
20
|
sortDir: z.enum(['asc', 'desc']).default('asc')
|
|
19
21
|
})
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
const clientFields = z.object({
|
|
24
|
+
clientType: z.enum(['organization', 'person']).default('organization'),
|
|
25
|
+
firstName: z.string().trim().min(1).max(80).optional(),
|
|
26
|
+
lastName: z.string().trim().min(1).max(80).optional(),
|
|
27
|
+
timezone: timezoneSchema.nullable().optional(),
|
|
22
28
|
name: z.string().trim().min(2).max(160),
|
|
23
|
-
slug,
|
|
24
|
-
officialName: z.string().trim().min(2).max(200),
|
|
29
|
+
slug: slug.optional(),
|
|
30
|
+
officialName: z.string().trim().min(2).max(200).optional(),
|
|
25
31
|
address: z.string().trim().max(1000).default(''),
|
|
26
32
|
registrationNumber: z.string().trim().max(200).nullable().optional(),
|
|
27
33
|
vatNumber: z.string().trim().max(100).nullable().optional(),
|
|
@@ -30,9 +36,31 @@ export const genericClientCreateSchema = z.object({
|
|
|
30
36
|
moduleIds: z.array(z.string().trim().min(1).max(100)).max(50).optional()
|
|
31
37
|
})
|
|
32
38
|
|
|
33
|
-
export const
|
|
34
|
-
.
|
|
39
|
+
export const genericClientCreateSchema = clientFields.superRefine((input, ctx) => {
|
|
40
|
+
if (input.clientType === 'organization') {
|
|
41
|
+
if (!input.slug) {
|
|
42
|
+
ctx.addIssue({ code: 'custom', path: ['slug'], message: 'Company slug is required' })
|
|
43
|
+
}
|
|
44
|
+
if (!input.officialName) {
|
|
45
|
+
ctx.addIssue({ code: 'custom', path: ['officialName'], message: 'Company name is required' })
|
|
46
|
+
}
|
|
47
|
+
} else if (input.registrationNumber || input.vatNumber) {
|
|
48
|
+
ctx.addIssue({ code: 'custom', message: 'Personal clients cannot have company registration or VAT fields' })
|
|
49
|
+
} else if (!input.firstName || !input.lastName) {
|
|
50
|
+
ctx.addIssue({ code: 'custom', path: ['firstName'], message: 'Personal clients require first and last name' })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
export const clientUpdateSchema = clientFields
|
|
54
|
+
.omit({ slug: true, moduleIds: true, clientType: true })
|
|
35
55
|
.partial()
|
|
56
|
+
.extend({
|
|
57
|
+
address: z.string().trim().max(1000).optional(),
|
|
58
|
+
preferredLocale: z.enum(['nl', 'en']).optional()
|
|
59
|
+
})
|
|
60
|
+
.strict()
|
|
61
|
+
.refine((value) => (value.firstName === undefined) === (value.lastName === undefined), {
|
|
62
|
+
message: 'First and last name must be updated together'
|
|
63
|
+
})
|
|
36
64
|
.refine((value) => Object.keys(value).length > 0, 'At least one field is required')
|
|
37
65
|
export const clientArchiveSchema = z.object({ archived: z.boolean() })
|
|
38
66
|
export const clientModuleUpdateSchema = z.object({ enabled: z.boolean() })
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { PoolClient } from 'pg'
|
|
3
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
4
|
+
import { createError } from 'h3'
|
|
5
|
+
import { createClientInTransaction } from './client-repository'
|
|
6
|
+
import { requireAllowedClientType } from './client-configuration'
|
|
7
|
+
|
|
8
|
+
export interface PurchaseClientInput {
|
|
9
|
+
type: 'person' | 'organization'
|
|
10
|
+
firstName: string
|
|
11
|
+
lastName: string
|
|
12
|
+
name: string
|
|
13
|
+
email: string
|
|
14
|
+
company: string
|
|
15
|
+
address: string
|
|
16
|
+
registrationNumber: string
|
|
17
|
+
vatNumber: string
|
|
18
|
+
clientId?: string
|
|
19
|
+
}
|
|
20
|
+
/** Never resolve company membership from unverified checkout fields. */
|
|
21
|
+
export async function provisionPurchaseClient(
|
|
22
|
+
tx: PoolClient,
|
|
23
|
+
input: PurchaseClientInput,
|
|
24
|
+
verifiedUserId: string | null,
|
|
25
|
+
actorId: string,
|
|
26
|
+
locale: 'en' | 'nl'
|
|
27
|
+
) {
|
|
28
|
+
await requireAllowedClientType(input.type)
|
|
29
|
+
await tx.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`purchase-client:${input.email}`])
|
|
30
|
+
let clientId: string | undefined
|
|
31
|
+
if (input.clientId && verifiedUserId) {
|
|
32
|
+
const result = await tx.query<{ id: string }>(
|
|
33
|
+
`SELECT p.organization_id AS id FROM clients.client_profile p JOIN public.member m ON m.organization_id=p.organization_id WHERE m.user_id=$1 AND p.organization_id=$2 AND p.client_type=$3 AND p.archived_at IS NULL`,
|
|
34
|
+
[verifiedUserId, input.clientId, input.type]
|
|
35
|
+
)
|
|
36
|
+
if (!result.rows[0]) {
|
|
37
|
+
throw createError({ statusCode: 403, message: 'Client membership required' })
|
|
38
|
+
}
|
|
39
|
+
clientId = result.rows[0].id
|
|
40
|
+
}
|
|
41
|
+
if (!clientId && input.type === 'person') {
|
|
42
|
+
// Reuse an existing personal billing client without granting membership or overwriting its profile.
|
|
43
|
+
const result = await tx.query<{ id: string }>(
|
|
44
|
+
`SELECT p.organization_id AS id FROM clients.client_profile p JOIN public.member m ON m.organization_id=p.organization_id JOIN public."user" u ON u.id=m.user_id WHERE lower(u.email)=$1 AND p.client_type='person' AND p.archived_at IS NULL LIMIT 1`,
|
|
45
|
+
[input.email]
|
|
46
|
+
)
|
|
47
|
+
clientId = result.rows[0]?.id
|
|
48
|
+
if (!clientId) {
|
|
49
|
+
const pending = await tx.query<{ id: string }>(
|
|
50
|
+
`SELECT p.organization_id AS id FROM clients.client_profile p JOIN public.invitation i ON i.organization_id=p.organization_id WHERE lower(i.email)=$1 AND p.client_type='person' AND p.archived_at IS NULL AND i.status='pending' ORDER BY i.expires_at DESC LIMIT 1`,
|
|
51
|
+
[input.email]
|
|
52
|
+
)
|
|
53
|
+
clientId = pending.rows[0]?.id
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!clientId) {
|
|
57
|
+
// Use the public client creation contract and its registered hooks in this transaction.
|
|
58
|
+
const database = drizzle(tx)
|
|
59
|
+
clientId = await createClientInTransaction(database, actorId, {
|
|
60
|
+
clientType: input.type,
|
|
61
|
+
name: input.type === 'person' ? input.name : input.company,
|
|
62
|
+
...(input.type === 'person' ? { firstName: input.firstName, lastName: input.lastName } : {}),
|
|
63
|
+
slug: `buyer-${randomUUID()}`,
|
|
64
|
+
officialName: input.type === 'person' ? input.name : input.company,
|
|
65
|
+
address: input.address,
|
|
66
|
+
registrationNumber: input.type === 'organization' ? input.registrationNumber || null : null,
|
|
67
|
+
vatNumber: input.type === 'organization' ? input.vatNumber || null : null,
|
|
68
|
+
invoiceEmail: input.email,
|
|
69
|
+
preferredLocale: locale,
|
|
70
|
+
moduleIds: ['invoices']
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
const existing = await tx.query(
|
|
74
|
+
`SELECT 1 FROM public.member m JOIN public."user" u ON u.id=m.user_id WHERE m.organization_id=$1 AND lower(u.email)=$2`,
|
|
75
|
+
[clientId, input.email]
|
|
76
|
+
)
|
|
77
|
+
let invitationId: string | null = null
|
|
78
|
+
if (!existing.rowCount) {
|
|
79
|
+
const pending = await tx.query<{ id: string }>(
|
|
80
|
+
"SELECT id FROM public.invitation WHERE organization_id=$1 AND lower(email)=$2 AND status='pending' LIMIT 1",
|
|
81
|
+
[clientId, input.email]
|
|
82
|
+
)
|
|
83
|
+
invitationId = pending.rows[0]?.id || randomUUID()
|
|
84
|
+
await tx.query(
|
|
85
|
+
`INSERT INTO public.invitation(id,organization_id,email,role,status,expires_at,inviter_id) VALUES($1,$2,$3,'owner','pending',now()+interval '7 days',$4) ON CONFLICT(id) DO UPDATE SET expires_at=now()+interval '7 days'`,
|
|
86
|
+
[invitationId, clientId, input.email, actorId]
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return { clientId, invitationId }
|
|
90
|
+
}
|
package/shared/feature.ts
CHANGED
|
@@ -48,3 +48,19 @@ export const clientsFeature: PortalFeatureDefinition<ClientAction> = {
|
|
|
48
48
|
CLIENT: { owner: ['read', 'update', 'manageMembers'], admin: ['read', 'update', 'manageMembers'], member: ['read'] }
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
export const clientsIcon = (allowedTypes: readonly string[] = []) =>
|
|
53
|
+
allowedTypes.includes('person') ? 'i-lucide-users-round' : 'i-lucide-building-2'
|
|
54
|
+
|
|
55
|
+
export const createClientsFeature = (allowedTypes: readonly string[]): typeof clientsFeature => {
|
|
56
|
+
const icon = clientsIcon(allowedTypes)
|
|
57
|
+
return {
|
|
58
|
+
...clientsFeature,
|
|
59
|
+
navigation: clientsFeature.navigation?.map((item) => ({ ...item, icon })),
|
|
60
|
+
modules: clientsFeature.modules?.map((item) => ({
|
|
61
|
+
...item,
|
|
62
|
+
icon,
|
|
63
|
+
menuItems: item.menuItems?.map((entry) => ({ ...entry, icon }))
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const privateClientInvitationSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
requestId: z.uuid(),
|
|
6
|
+
clientId: z.string().min(1).optional(),
|
|
7
|
+
name: z.string().trim().min(2).max(160).optional(),
|
|
8
|
+
email: z.string().trim().toLowerCase().email(),
|
|
9
|
+
preferredLocale: z.enum(['en', 'nl']).default('en')
|
|
10
|
+
})
|
|
11
|
+
.refine((value) => Boolean(value.clientId || value.name), { message: 'Choose a client or enter a name' })
|
package/shared/types/client.ts
CHANGED
|
@@ -24,8 +24,13 @@ export interface GenericClientDto {
|
|
|
24
24
|
id: string
|
|
25
25
|
organizationId: string
|
|
26
26
|
name: string
|
|
27
|
+
firstName: string | null
|
|
28
|
+
lastName: string | null
|
|
27
29
|
slug: string
|
|
28
30
|
logo: string | null
|
|
31
|
+
clientType: 'organization' | 'person'
|
|
32
|
+
timezone: string | null
|
|
33
|
+
schedulingTimezone: string
|
|
29
34
|
officialName: string
|
|
30
35
|
address: string
|
|
31
36
|
registrationNumber: string | null
|