@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +4 -0
  3. package/app/components/ClientsClientDetail.vue +38 -16
  4. package/app/components/ClientsClientForm.vue +134 -21
  5. package/app/components/ClientsClientPicker.vue +7 -1
  6. package/app/components/ClientsPersonalAddress.vue +58 -0
  7. package/app/components/ClientsPersonalProfile.vue +46 -0
  8. package/app/components/ClientsPrivateInvitation.vue +110 -0
  9. package/app/composables/useClients.ts +21 -0
  10. package/app/middleware/personal-onboarding.global.ts +14 -0
  11. package/app/pages/clients/[id].vue +7 -1
  12. package/app/pages/clients/index.vue +91 -8
  13. package/app/pages/personal-onboarding.vue +79 -0
  14. package/app/plugins/clients-feature.ts +10 -2
  15. package/i18n/locales/en.json +28 -2
  16. package/i18n/locales/nl.json +28 -2
  17. package/migrations/0001_person_clients.sql +37 -0
  18. package/migrations/0002_person_names.sql +18 -0
  19. package/nuxt.config.ts +7 -1
  20. package/package.json +3 -3
  21. package/portal.manifest.mjs +1 -1
  22. package/server/api/admin/private-client-invitations/index.get.ts +27 -0
  23. package/server/api/admin/private-client-invitations/index.post.ts +137 -0
  24. package/server/api/client-account.get.ts +12 -0
  25. package/server/api/clients/[id]/index.patch.ts +5 -1
  26. package/server/api/clients/[id]/invitations/[invitationId]/resend.post.ts +78 -0
  27. package/server/api/clients/[id]/invitations/[invitationId].patch.ts +2 -2
  28. package/server/api/clients/[id]/invitations/index.post.ts +35 -12
  29. package/server/api/clients/[id]/members/[memberId].delete.ts +2 -2
  30. package/server/api/clients/[id]/members/[memberId].patch.ts +6 -2
  31. package/server/api/clients/index.post.ts +5 -1
  32. package/server/api/personal-client/address.get.ts +15 -0
  33. package/server/api/personal-client/address.patch.ts +32 -0
  34. package/server/api/personal-client.get.ts +43 -0
  35. package/server/api/personal-client.post.ts +77 -0
  36. package/server/db/schema/clients.ts +29 -3
  37. package/server/middleware/00-client-configuration.ts +5 -0
  38. package/server/plugins/client-accounts.ts +114 -0
  39. package/server/utils/client-access.ts +9 -0
  40. package/server/utils/client-configuration.ts +37 -0
  41. package/server/utils/client-repository.ts +132 -63
  42. package/server/utils/client-validation.ts +33 -5
  43. package/server/utils/purchase-client.ts +90 -0
  44. package/shared/feature.ts +16 -0
  45. package/shared/personal-onboarding.ts +6 -0
  46. package/shared/private-client-invitation.ts +11 -0
  47. 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
- id: row.organizationId,
83
- organizationId: row.organizationId,
84
- name: row.name,
85
- slug: row.slug,
86
- logo: row.logo,
87
- avatarLogo: getOrganizationAvatar(row) ?? null,
88
- officialName: row.officialName,
89
- address: row.address,
90
- registrationNumber: row.registrationNumber,
91
- vatNumber: row.vatNumber,
92
- invoiceEmail: row.invoiceEmail,
93
- preferredLocale: row.preferredLocale === 'en' ? 'en' : 'nl',
94
- archivedAt: row.archivedAt?.toISOString() ?? null,
95
- modules: modules
96
- .filter((item) => item.organizationId === row.organizationId)
97
- .map((item) => ({ moduleId: item.moduleId, enabled: item.enabled })),
98
- members: members
99
- .filter((item) => item.organizationId === row.organizationId)
100
- .map((item) => ({
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
- invitations: invitations
111
- .filter((item) => item.organizationId === row.organizationId)
112
- .map((item) => ({
113
- id: item.id,
114
- email: item.email,
115
- role: item.role || 'member',
116
- status: item.status,
117
- expiresAt: item.expiresAt.toISOString()
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
- export const createClient = async (actorUserId: string, input: ClientCreateInput) =>
206
- db.transaction(async (tx) => {
207
- const [existingSlug] = await tx
208
- .select({ id: organization.id })
209
- .from(organization)
210
- .where(eq(organization.slug, input.slug))
211
- .limit(1)
212
- if (existingSlug) {
213
- throw createError({ statusCode: 409, message: 'Organization slug already exists', data: { field: 'slug' } })
214
- }
215
- const organizationId = nanoid()
216
- await tx.insert(organization).values({
217
- id: organizationId,
218
- name: input.name,
219
- slug: input.slug,
220
- organizationType: 'CLIENT',
221
- createdAt: new Date()
222
- })
223
- await tx.insert(clientProfile).values({
224
- organizationId,
225
- officialName: input.officialName,
226
- address: input.address,
227
- registrationNumber: normalizeNullable(input.registrationNumber),
228
- vatNumber: normalizeNullable(input.vatNumber),
229
- invoiceEmail: normalizeNullable(input.invoiceEmail),
230
- preferredLocale: input.preferredLocale
231
- })
232
- await runClientCreatedHooks(tx, organizationId)
233
- for (const moduleId of [...new Set(input.moduleIds ?? [])]) {
234
- await tx.insert(clientModule).values({ id: nanoid(), organizationId, moduleId, enabledById: actorUserId })
235
- }
236
- return organizationId
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
- export const genericClientCreateSchema = z.object({
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 clientUpdateSchema = genericClientCreateSchema
34
- .omit({ slug: true, moduleIds: true })
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,6 @@
1
+ export const needsPersonalOnboarding = (input: {
2
+ enabled: boolean
3
+ emailVerified: boolean
4
+ membershipCount: number
5
+ hasPendingInvitation: boolean
6
+ }) => input.enabled && input.emailVerified && input.membershipCount === 0 && !input.hasPendingInvitation
@@ -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' })
@@ -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