@nuxt-customer-portal/clients 0.3.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 (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +3 -0
  4. package/app/components/ClientsClientDetail.vue +407 -0
  5. package/app/components/ClientsClientForm.vue +135 -0
  6. package/app/components/ClientsClientPicker.vue +24 -0
  7. package/app/composables/useClients.ts +25 -0
  8. package/app/pages/clients/[id].vue +47 -0
  9. package/app/pages/clients/index.vue +265 -0
  10. package/app/plugins/clients-feature.ts +3 -0
  11. package/i18n/locales/en.json +74 -0
  12. package/i18n/locales/nl.json +74 -0
  13. package/migrations/0000_baseline.sql +33 -0
  14. package/nuxt.config.ts +17 -0
  15. package/package.json +55 -0
  16. package/portal.manifest.mjs +8 -0
  17. package/server/api/clients/[id]/archive/index.patch.ts +11 -0
  18. package/server/api/clients/[id]/deletion/index.get.ts +7 -0
  19. package/server/api/clients/[id]/index.delete.ts +10 -0
  20. package/server/api/clients/[id]/index.get.ts +12 -0
  21. package/server/api/clients/[id]/index.patch.ts +10 -0
  22. package/server/api/clients/[id]/invitations/[invitationId].patch.ts +8 -0
  23. package/server/api/clients/[id]/invitations/index.post.ts +66 -0
  24. package/server/api/clients/[id]/members/[memberId].delete.ts +29 -0
  25. package/server/api/clients/[id]/members/[memberId].patch.ts +20 -0
  26. package/server/api/clients/[id]/modules/[moduleId].put.ts +12 -0
  27. package/server/api/clients/index.get.ts +8 -0
  28. package/server/api/clients/index.post.ts +10 -0
  29. package/server/api/clients/selectable.get.ts +11 -0
  30. package/server/db/schema/clients.ts +55 -0
  31. package/server/utils/client-access.ts +23 -0
  32. package/server/utils/client-email-locale.ts +18 -0
  33. package/server/utils/client-repository.ts +390 -0
  34. package/server/utils/client-validation.ts +57 -0
  35. package/shared/feature.ts +50 -0
  36. package/shared/types/client.ts +51 -0
@@ -0,0 +1,66 @@
1
+ import { and, eq } from 'drizzle-orm'
2
+ import { db } from '@nuxt-customer-portal/core/server/portal'
3
+ import { generateId } from '@nuxt-customer-portal/core/server/utils/auth'
4
+ import { invitation, organization } from '@nuxt-customer-portal/core/schema'
5
+ 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'
8
+ import { genericClientInvitationSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
9
+
10
+ export default defineEventHandler(async (event) => {
11
+ const organizationId = getRouterParam(event, 'id')!
12
+ const context = await requireClientProfileManager(event, organizationId)
13
+ const parsedInput = genericClientInvitationSchema.safeParse(await readBody(event))
14
+ if (!parsedInput.success) {
15
+ throw createError({ statusCode: 400, message: 'A valid email address and role are required' })
16
+ }
17
+ const input = parsedInput.data
18
+ const [pending] = await db
19
+ .select({ id: invitation.id })
20
+ .from(invitation)
21
+ .where(
22
+ and(
23
+ eq(invitation.organizationId, organizationId),
24
+ eq(invitation.email, input.email),
25
+ eq(invitation.status, 'pending')
26
+ )
27
+ )
28
+ .limit(1)
29
+ if (pending) {
30
+ throw createError({ statusCode: 409, message: 'A pending invitation already exists' })
31
+ }
32
+ const [client] = await db
33
+ .select({ name: organization.name })
34
+ .from(organization)
35
+ .where(and(eq(organization.id, organizationId), eq(organization.organizationType, 'CLIENT')))
36
+ .limit(1)
37
+ if (!client) {
38
+ throw createError({ statusCode: 404, message: 'Client not found' })
39
+ }
40
+ const id = generateId()
41
+ const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
42
+ const [created] = await db
43
+ .insert(invitation)
44
+ .values({
45
+ id,
46
+ email: input.email,
47
+ role: input.role,
48
+ organizationId,
49
+ inviterId: context.session.user.id,
50
+ status: 'pending',
51
+ expiresAt
52
+ })
53
+ .returning()
54
+ const baseURL = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3000'
55
+ await sendEmail({
56
+ 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
+ })
64
+ })
65
+ return created
66
+ })
@@ -0,0 +1,29 @@
1
+ import { and, count, eq } from 'drizzle-orm'
2
+ import { db } from '@nuxt-customer-portal/core/server/portal'
3
+ import { member } from '@nuxt-customer-portal/core/schema'
4
+ import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const organizationId = getRouterParam(event, 'id')!
8
+ await requireClientProfileManager(event, organizationId)
9
+ const memberId = getRouterParam(event, 'memberId')!
10
+ const [selected] = await db
11
+ .select({ role: member.role })
12
+ .from(member)
13
+ .where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
14
+ .limit(1)
15
+ if (!selected) {
16
+ throw createError({ statusCode: 404, message: 'Client member not found' })
17
+ }
18
+ if (selected.role === 'owner') {
19
+ const [owners] = await db
20
+ .select({ total: count() })
21
+ .from(member)
22
+ .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
23
+ if (Number(owners?.total ?? 0) <= 1) {
24
+ throw createError({ statusCode: 409, message: 'The last client owner cannot be removed' })
25
+ }
26
+ }
27
+ await db.delete(member).where(eq(member.id, memberId))
28
+ return { deleted: true }
29
+ })
@@ -0,0 +1,20 @@
1
+ import { and, eq } from 'drizzle-orm'
2
+ import { db } from '@nuxt-customer-portal/core/server/portal'
3
+ import { member } from '@nuxt-customer-portal/core/schema'
4
+ import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
5
+ import { genericClientMemberUpdateSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ const organizationId = getRouterParam(event, 'id')!
9
+ await requireClientProfileManager(event, organizationId)
10
+ const input = genericClientMemberUpdateSchema.parse(await readBody(event))
11
+ const [updated] = await db
12
+ .update(member)
13
+ .set(input)
14
+ .where(and(eq(member.id, getRouterParam(event, 'memberId')!), eq(member.organizationId, organizationId)))
15
+ .returning()
16
+ if (!updated) {
17
+ throw createError({ statusCode: 404, message: 'Client member not found' })
18
+ }
19
+ return updated
20
+ })
@@ -0,0 +1,12 @@
1
+ import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { getClient, setClientModule } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+ import { clientModuleUpdateSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const id = getRouterParam(event, 'id')!
7
+ const moduleId = getRouterParam(event, 'moduleId')!
8
+ const { session } = await requireOwnerClientManager(event)
9
+ const { enabled } = clientModuleUpdateSchema.parse(await readBody(event))
10
+ await setClientModule(id, session.user.id, moduleId, enabled)
11
+ return getClient(id)
12
+ })
@@ -0,0 +1,8 @@
1
+ import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { listGenericClientsPage } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+ import { genericClientListQuerySchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ await requireOwnerClientManager(event)
7
+ return listGenericClientsPage(genericClientListQuerySchema.parse(getQuery(event)))
8
+ })
@@ -0,0 +1,10 @@
1
+ import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { createClient, getClient } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+ import { genericClientCreateSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session } = await requireOwnerClientManager(event)
7
+ const input = genericClientCreateSchema.parse(await readBody(event))
8
+ const organizationId = await createClient(session.user.id, input)
9
+ return getClient(organizationId)
10
+ })
@@ -0,0 +1,11 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listSelectableClients } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const context = await requireActiveOrganizationRole(event)
6
+ if (context.organizationType !== 'PROVIDER') {
7
+ throw createError({ statusCode: 403, message: 'PROVIDER organization access required' })
8
+ }
9
+ const moduleId = getQuery(event).moduleId
10
+ return listSelectableClients(typeof moduleId === 'string' && moduleId ? moduleId : undefined)
11
+ })
@@ -0,0 +1,55 @@
1
+ import { boolean, index, pgSchema, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
2
+ import { organization, user } from '@nuxt-customer-portal/core/schema'
3
+
4
+ export const clientsSchema = pgSchema('clients')
5
+
6
+ const auditColumns = {
7
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
8
+ updatedAt: timestamp('updated_at', { mode: 'date' })
9
+ .defaultNow()
10
+ .$onUpdate(() => new Date())
11
+ .notNull()
12
+ }
13
+
14
+ export const clientProfile = clientsSchema.table(
15
+ 'client_profile',
16
+ {
17
+ organizationId: text('organization_id')
18
+ .primaryKey()
19
+ .references(() => organization.id, { onDelete: 'restrict' }),
20
+ officialName: text('official_name').notNull(),
21
+ address: text('address').default('').notNull(),
22
+ registrationNumber: text('registration_number'),
23
+ vatNumber: text('vat_number'),
24
+ invoiceEmail: text('invoice_email'),
25
+ preferredLocale: text('preferred_locale').default('nl').notNull(),
26
+ archivedAt: timestamp('archived_at', { mode: 'date' }),
27
+ archivedById: text('archived_by_id').references(() => user.id, { onDelete: 'set null' }),
28
+ ...auditColumns
29
+ },
30
+ (table) => [index('client_profile_archived_idx').on(table.archivedAt)]
31
+ )
32
+
33
+ export const clientModule = clientsSchema.table(
34
+ 'client_module',
35
+ {
36
+ id: text('id').primaryKey(),
37
+ organizationId: text('organization_id')
38
+ .notNull()
39
+ .references(() => organization.id, { onDelete: 'restrict' }),
40
+ moduleId: text('module_id').notNull(),
41
+ enabled: boolean('enabled').default(true).notNull(),
42
+ enabledAt: timestamp('enabled_at', { mode: 'date' }).defaultNow().notNull(),
43
+ enabledById: text('enabled_by_id').references(() => user.id, { onDelete: 'set null' }),
44
+ disabledAt: timestamp('disabled_at', { mode: 'date' }),
45
+ disabledById: text('disabled_by_id').references(() => user.id, { onDelete: 'set null' }),
46
+ ...auditColumns
47
+ },
48
+ (table) => [
49
+ uniqueIndex('client_module_org_module_uidx').on(table.organizationId, table.moduleId),
50
+ index('client_module_org_enabled_idx').on(table.organizationId, table.enabled)
51
+ ]
52
+ )
53
+
54
+ export type ClientProfileRecord = typeof clientProfile.$inferSelect
55
+ export type ClientModuleRecord = typeof clientModule.$inferSelect
@@ -0,0 +1,23 @@
1
+ import type { H3Event } from 'h3'
2
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
3
+
4
+ export const requireOwnerClientManager = async (event: H3Event) => {
5
+ const context = await requireActiveOrganizationRole(event)
6
+ if (context.organizationType !== 'PROVIDER' || !['owner', 'admin'].includes(context.role ?? '')) {
7
+ throw createError({ statusCode: 403, message: 'PROVIDER organization administrator access required' })
8
+ }
9
+ return context
10
+ }
11
+
12
+ export const requireClientProfileManager = async (event: H3Event, clientOrganizationId: string) => {
13
+ const context = await requireActiveOrganizationRole(event)
14
+ const providerManager = context.organizationType === 'PROVIDER' && ['owner', 'admin'].includes(context.role ?? '')
15
+ const clientManager =
16
+ context.organizationType === 'CLIENT' &&
17
+ context.organizationId === clientOrganizationId &&
18
+ ['owner', 'admin'].includes(context.role ?? '')
19
+ if (!providerManager && !clientManager) {
20
+ throw createError({ statusCode: 403, message: 'Client administrator access required' })
21
+ }
22
+ return context
23
+ }
@@ -0,0 +1,18 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { db } from '@nuxt-customer-portal/core/server/portal'
3
+ import { clientProfile } from '../db/schema/clients'
4
+
5
+ export const getClientEmailLocale = async (
6
+ organizationId: string | null,
7
+ fallback: 'nl' | 'en' = 'nl'
8
+ ): Promise<'nl' | 'en'> => {
9
+ if (!organizationId) {
10
+ return fallback
11
+ }
12
+ const [profile] = await db
13
+ .select({ locale: clientProfile.preferredLocale })
14
+ .from(clientProfile)
15
+ .where(eq(clientProfile.organizationId, organizationId))
16
+ .limit(1)
17
+ return profile ? (profile.locale === 'en' ? 'en' : 'nl') : fallback
18
+ }
@@ -0,0 +1,390 @@
1
+ import { and, asc, count, desc, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm'
2
+ import { nanoid } from 'nanoid'
3
+ import { getOrganizationAvatar } from '@nuxt-customer-portal/core/shared/organization-avatar'
4
+ import { db } from '@nuxt-customer-portal/core/server/portal'
5
+ import { invitation, member, organization, user } from '@nuxt-customer-portal/core/schema'
6
+ import { clientModule, clientProfile } from '@nuxt-customer-portal/clients/server/db/schema/clients'
7
+ import { runClientCreatedHooks } from '@nuxt-customer-portal/core/server/utils/business-hooks'
8
+ import type { GenericClientDto, ClientListResponse } from '@nuxt-customer-portal/clients/shared/types/client'
9
+ import type { ClientCreateInput, GenericClientListQuery, ClientUpdateInput } from './client-validation'
10
+
11
+ const normalizeNullable = (value: string | null | undefined) => value?.trim() || null
12
+
13
+ const clientSelection = {
14
+ organizationId: organization.id,
15
+ name: organization.name,
16
+ slug: organization.slug,
17
+ logo: organization.logo,
18
+ metadata: organization.metadata,
19
+ createdAt: organization.createdAt,
20
+ officialName: clientProfile.officialName,
21
+ address: clientProfile.address,
22
+ registrationNumber: clientProfile.registrationNumber,
23
+ vatNumber: clientProfile.vatNumber,
24
+ invoiceEmail: clientProfile.invoiceEmail,
25
+ preferredLocale: clientProfile.preferredLocale,
26
+ archivedAt: clientProfile.archivedAt
27
+ }
28
+
29
+ interface ClientRow {
30
+ organizationId: string
31
+ name: string
32
+ slug: string
33
+ logo: string | null
34
+ metadata: string | null
35
+ createdAt: Date
36
+ officialName: string
37
+ address: string
38
+ registrationNumber: string | null
39
+ vatNumber: string | null
40
+ invoiceEmail: string | null
41
+ preferredLocale: string
42
+ archivedAt: Date | null
43
+ }
44
+
45
+ const hydrateClients = async (rows: ClientRow[]): Promise<GenericClientDto[]> => {
46
+ if (!rows.length) {
47
+ return []
48
+ }
49
+ const ids = rows.map((row) => row.organizationId as string)
50
+ const [modules, members, invitations] = await Promise.all([
51
+ db.select().from(clientModule).where(inArray(clientModule.organizationId, ids)),
52
+ db
53
+ .select({
54
+ id: member.id,
55
+ organizationId: member.organizationId,
56
+ userId: member.userId,
57
+ role: member.role,
58
+ phone: member.phone,
59
+ jobTitle: member.jobTitle,
60
+ name: user.name,
61
+ email: user.email,
62
+ image: user.image
63
+ })
64
+ .from(member)
65
+ .innerJoin(user, eq(user.id, member.userId))
66
+ .where(inArray(member.organizationId, ids))
67
+ .orderBy(asc(user.name), asc(user.email)),
68
+ db
69
+ .select({
70
+ id: invitation.id,
71
+ organizationId: invitation.organizationId,
72
+ email: invitation.email,
73
+ role: invitation.role,
74
+ status: invitation.status,
75
+ expiresAt: invitation.expiresAt
76
+ })
77
+ .from(invitation)
78
+ .where(and(inArray(invitation.organizationId, ids), eq(invitation.status, 'pending')))
79
+ .orderBy(asc(invitation.email))
80
+ ])
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) => ({
101
+ id: item.id,
102
+ userId: item.userId,
103
+ name: item.name,
104
+ email: item.email,
105
+ image: item.image,
106
+ role: item.role,
107
+ phone: item.phone,
108
+ jobTitle: item.jobTitle
109
+ })),
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
+ }))
120
+ }
121
+
122
+ export const listGenericClientsPage = async (query: GenericClientListQuery): Promise<ClientListResponse> => {
123
+ const conditions = [
124
+ eq(organization.organizationType, 'CLIENT'),
125
+ query.status === 'archived'
126
+ ? isNotNull(clientProfile.archivedAt)
127
+ : query.status === 'active'
128
+ ? isNull(clientProfile.archivedAt)
129
+ : undefined,
130
+ query.search
131
+ ? or(
132
+ ilike(organization.name, `%${query.search}%`),
133
+ ilike(clientProfile.officialName, `%${query.search}%`),
134
+ ilike(organization.slug, `%${query.search}%`)
135
+ )
136
+ : undefined,
137
+ query.moduleId
138
+ ? inArray(
139
+ organization.id,
140
+ db
141
+ .select({ id: clientModule.organizationId })
142
+ .from(clientModule)
143
+ .where(and(eq(clientModule.moduleId, query.moduleId), eq(clientModule.enabled, true)))
144
+ )
145
+ : undefined
146
+ ].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition))
147
+ const where = and(...conditions)
148
+ const [countRow] = await db
149
+ .select({ total: count() })
150
+ .from(organization)
151
+ .innerJoin(clientProfile, eq(clientProfile.organizationId, organization.id))
152
+ .where(where)
153
+ const direction = query.sortDir === 'desc' ? desc : asc
154
+ const primary =
155
+ query.sortBy === 'createdAt'
156
+ ? organization.createdAt
157
+ : query.sortBy === 'status'
158
+ ? clientProfile.archivedAt
159
+ : organization.name
160
+ const rows = await db
161
+ .select(clientSelection)
162
+ .from(organization)
163
+ .innerJoin(clientProfile, eq(clientProfile.organizationId, organization.id))
164
+ .where(where)
165
+ .orderBy(direction(primary), asc(organization.id))
166
+ .limit(query.pageSize)
167
+ .offset((query.page - 1) * query.pageSize)
168
+ const totalItems = Number(countRow?.total ?? 0)
169
+ const totalPages = Math.max(1, Math.ceil(totalItems / query.pageSize))
170
+ return {
171
+ items: await hydrateClients(rows),
172
+ pagination: {
173
+ page: query.page,
174
+ pageSize: query.pageSize,
175
+ totalItems,
176
+ totalPages,
177
+ hasPrevious: query.page > 1,
178
+ hasNext: query.page < totalPages
179
+ }
180
+ }
181
+ }
182
+
183
+ export const getClient = async (organizationId: string): Promise<GenericClientDto | null> => {
184
+ const rows = await db
185
+ .select(clientSelection)
186
+ .from(organization)
187
+ .innerJoin(clientProfile, eq(clientProfile.organizationId, organization.id))
188
+ .where(and(eq(organization.id, organizationId), eq(organization.organizationType, 'CLIENT')))
189
+ .limit(1)
190
+ return (await hydrateClients(rows))[0] ?? null
191
+ }
192
+
193
+ export const listSelectableClients = async (moduleId?: string) => {
194
+ const page = await listGenericClientsPage({
195
+ status: 'active',
196
+ moduleId,
197
+ page: 1,
198
+ pageSize: 100,
199
+ sortBy: 'name',
200
+ sortDir: 'asc'
201
+ })
202
+ return page.items
203
+ }
204
+
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
237
+ })
238
+
239
+ export const updateClient = async (organizationId: string, input: ClientUpdateInput) =>
240
+ db.transaction(async (tx) => {
241
+ const [selected] = await tx
242
+ .select({ id: organization.id })
243
+ .from(organization)
244
+ .where(and(eq(organization.id, organizationId), eq(organization.organizationType, 'CLIENT')))
245
+ .limit(1)
246
+ if (!selected) {
247
+ throw createError({ statusCode: 404, message: 'Client not found' })
248
+ }
249
+ const orgValues: Record<string, unknown> = {}
250
+ if (input.name !== undefined) {
251
+ orgValues.name = input.name
252
+ }
253
+ if (Object.keys(orgValues).length) {
254
+ await tx.update(organization).set(orgValues).where(eq(organization.id, organizationId))
255
+ }
256
+ const values: Record<string, unknown> = { updatedAt: new Date() }
257
+ if (input.officialName !== undefined) {
258
+ values.officialName = input.officialName
259
+ }
260
+ if (input.address !== undefined) {
261
+ values.address = input.address
262
+ }
263
+ if (input.registrationNumber !== undefined) {
264
+ values.registrationNumber = normalizeNullable(input.registrationNumber)
265
+ }
266
+ if (input.vatNumber !== undefined) {
267
+ values.vatNumber = normalizeNullable(input.vatNumber)
268
+ }
269
+ if (input.invoiceEmail !== undefined) {
270
+ values.invoiceEmail = normalizeNullable(input.invoiceEmail)
271
+ }
272
+ if (input.preferredLocale !== undefined) {
273
+ values.preferredLocale = input.preferredLocale
274
+ }
275
+ await tx.update(clientProfile).set(values).where(eq(clientProfile.organizationId, organizationId))
276
+ })
277
+
278
+ export const setClientArchived = async (organizationId: string, actorUserId: string, archived: boolean) => {
279
+ const [updated] = await db
280
+ .update(clientProfile)
281
+ .set({
282
+ archivedAt: archived ? new Date() : null,
283
+ archivedById: archived ? actorUserId : null,
284
+ updatedAt: new Date()
285
+ })
286
+ .where(eq(clientProfile.organizationId, organizationId))
287
+ .returning({ organizationId: clientProfile.organizationId })
288
+ if (!updated) {
289
+ throw createError({ statusCode: 404, message: 'Client not found' })
290
+ }
291
+ }
292
+
293
+ export const setClientModule = async (
294
+ organizationId: string,
295
+ actorUserId: string,
296
+ moduleId: string,
297
+ enabled: boolean
298
+ ) => {
299
+ const selected = await getClient(organizationId)
300
+ if (!selected) {
301
+ throw createError({ statusCode: 404, message: 'Client not found' })
302
+ }
303
+ if (selected.archivedAt && enabled) {
304
+ throw createError({ statusCode: 409, message: 'Archived clients cannot enable modules' })
305
+ }
306
+ await db
307
+ .insert(clientModule)
308
+ .values({
309
+ id: nanoid(),
310
+ organizationId,
311
+ moduleId,
312
+ enabled,
313
+ enabledAt: new Date(),
314
+ enabledById: actorUserId,
315
+ disabledAt: enabled ? null : new Date(),
316
+ disabledById: enabled ? null : actorUserId
317
+ })
318
+ .onConflictDoUpdate({
319
+ target: [clientModule.organizationId, clientModule.moduleId],
320
+ set: {
321
+ enabled,
322
+ enabledAt: enabled ? new Date() : undefined,
323
+ enabledById: enabled ? actorUserId : undefined,
324
+ disabledAt: enabled ? null : new Date(),
325
+ disabledById: enabled ? null : actorUserId,
326
+ updatedAt: new Date()
327
+ }
328
+ })
329
+ }
330
+
331
+ export const requireClientModuleEnabled = async (organizationId: string, moduleId: string) => {
332
+ const [enabled] = await db
333
+ .select({ id: clientModule.id })
334
+ .from(clientModule)
335
+ .innerJoin(clientProfile, eq(clientProfile.organizationId, clientModule.organizationId))
336
+ .where(
337
+ and(
338
+ eq(clientModule.organizationId, organizationId),
339
+ eq(clientModule.moduleId, moduleId),
340
+ eq(clientModule.enabled, true),
341
+ isNull(clientProfile.archivedAt)
342
+ )
343
+ )
344
+ .limit(1)
345
+ if (!enabled) {
346
+ throw createError({ statusCode: 403, message: `Module ${moduleId} is not enabled for this client` })
347
+ }
348
+ }
349
+
350
+ export const getGenericClientDeletionEligibility = async (organizationId: string) => {
351
+ const selected = await getClient(organizationId)
352
+ if (!selected) {
353
+ throw createError({ statusCode: 404, message: 'Client not found' })
354
+ }
355
+ return {
356
+ organizationId,
357
+ clientName: selected.name,
358
+ canDelete: selected.members.length === 0 && selected.modules.length === 0,
359
+ memberCount: selected.members.length,
360
+ moduleCount: selected.modules.length
361
+ }
362
+ }
363
+
364
+ export const deleteGenericClient = async (organizationId: string, clientName: string) =>
365
+ db.transaction(async (tx) => {
366
+ const [selected] = await tx
367
+ .select({ name: organization.name })
368
+ .from(organization)
369
+ .where(and(eq(organization.id, organizationId), eq(organization.organizationType, 'CLIENT')))
370
+ .limit(1)
371
+ if (!selected) {
372
+ throw createError({ statusCode: 404, message: 'Client not found' })
373
+ }
374
+ if (selected.name !== clientName) {
375
+ throw createError({ statusCode: 400, message: 'Client name does not match' })
376
+ }
377
+ const [memberCount] = await tx
378
+ .select({ members: count() })
379
+ .from(member)
380
+ .where(eq(member.organizationId, organizationId))
381
+ const [moduleCount] = await tx
382
+ .select({ modules: count() })
383
+ .from(clientModule)
384
+ .where(eq(clientModule.organizationId, organizationId))
385
+ if (Number(memberCount?.members ?? 0) || Number(moduleCount?.modules ?? 0)) {
386
+ throw createError({ statusCode: 409, message: 'Client still has members or module history' })
387
+ }
388
+ await tx.delete(clientProfile).where(eq(clientProfile.organizationId, organizationId))
389
+ await tx.delete(organization).where(eq(organization.id, organizationId))
390
+ })