@nuxt-customer-portal/clients 0.3.2 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +42 -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,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
- await updateClient(id, clientUpdateSchema.parse(await readBody(event)))
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 { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
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 requireClientProfileManager(event, organizationId)
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 { getInvitationEmailContent } from '@nuxt-customer-portal/core/server/utils/email-texts'
7
- import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
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 requireClientProfileManager(event, organizationId)
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
- ...getInvitationEmailContent({
58
- inviterName: context.session.user.name || '',
59
- inviterEmail: context.session.user.email ?? '',
60
- organizationName: client.name,
61
- role: input.role,
62
- invitationLink: `${baseURL}/signup?invitationId=${id}`
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 { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
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 requireClientProfileManager(event, organizationId)
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 { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
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 requireClientProfileManager(event, organizationId)
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 input = genericClientCreateSchema.parse(await readBody(event))
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 { boolean, index, pgSchema, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
2
- import { organization, user } from '@nuxt-customer-portal/core/schema'
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) => [index('client_profile_archived_idx').on(table.archivedAt)]
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
+ })
@@ -0,0 +1,5 @@
1
+ import { requireClientConfigurationReady } from '../utils/client-configuration'
2
+
3
+ export default defineEventHandler(async () => {
4
+ await requireClientConfigurationReady()
5
+ })