@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.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/app/components/ClientsClientDetail.vue +407 -0
- package/app/components/ClientsClientForm.vue +135 -0
- package/app/components/ClientsClientPicker.vue +24 -0
- package/app/composables/useClients.ts +25 -0
- package/app/pages/clients/[id].vue +47 -0
- package/app/pages/clients/index.vue +265 -0
- package/app/plugins/clients-feature.ts +3 -0
- package/i18n/locales/en.json +74 -0
- package/i18n/locales/nl.json +74 -0
- package/migrations/0000_baseline.sql +33 -0
- package/nuxt.config.ts +17 -0
- package/package.json +55 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/clients/[id]/archive/index.patch.ts +11 -0
- package/server/api/clients/[id]/deletion/index.get.ts +7 -0
- package/server/api/clients/[id]/index.delete.ts +10 -0
- package/server/api/clients/[id]/index.get.ts +12 -0
- package/server/api/clients/[id]/index.patch.ts +10 -0
- package/server/api/clients/[id]/invitations/[invitationId].patch.ts +8 -0
- package/server/api/clients/[id]/invitations/index.post.ts +66 -0
- package/server/api/clients/[id]/members/[memberId].delete.ts +29 -0
- package/server/api/clients/[id]/members/[memberId].patch.ts +20 -0
- package/server/api/clients/[id]/modules/[moduleId].put.ts +12 -0
- package/server/api/clients/index.get.ts +8 -0
- package/server/api/clients/index.post.ts +10 -0
- package/server/api/clients/selectable.get.ts +11 -0
- package/server/db/schema/clients.ts +55 -0
- package/server/utils/client-access.ts +23 -0
- package/server/utils/client-email-locale.ts +18 -0
- package/server/utils/client-repository.ts +390 -0
- package/server/utils/client-validation.ts +57 -0
- package/shared/feature.ts +50 -0
- 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
|
+
})
|