@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,265 @@
1
+ <script setup lang="ts">
2
+ import type { ClientListResponse, GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
3
+
4
+ const { t } = useI18n()
5
+ const route = useRoute()
6
+ const router = useRouter()
7
+ const toast = useToast()
8
+ const api = useClients()
9
+ const runtimeConfig = useRuntimeConfig()
10
+ const defaultModules = (runtimeConfig.public.clients as { defaultModules?: string[] } | undefined)?.defaultModules ?? []
11
+ const search = ref(String(route.query.search ?? ''))
12
+ const status = ref(route.query.status === 'archived' ? 'archived' : route.query.status === 'all' ? 'all' : 'active')
13
+ const sortBy = ref(
14
+ ['name', 'createdAt', 'status'].includes(String(route.query.sortBy)) ? String(route.query.sortBy) : 'name'
15
+ )
16
+ const sortDir = ref<'asc' | 'desc'>(route.query.sortDir === 'desc' ? 'desc' : 'asc')
17
+ const page = ref(Math.max(1, Number(route.query.page) || 1))
18
+ const result = ref<ClientListResponse | null>(null)
19
+ const pending = ref(false)
20
+ const busy = ref(false)
21
+ const formOpen = ref(false)
22
+ const listContainer = ref<HTMLElement | null>(null)
23
+ const listScrollTop = ref(0)
24
+ const scrollRestored = ref(false)
25
+ const clients = computed(() => result.value?.items ?? [])
26
+ const statusOptions = computed(() => [
27
+ { label: t('features.clients.all'), value: 'all' },
28
+ { label: t('features.clients.active'), value: 'active' },
29
+ { label: t('features.clients.archived'), value: 'archived' }
30
+ ])
31
+ const sortOptions = computed(() => [
32
+ { label: t('features.clients.sortName'), value: 'name' },
33
+ { label: t('features.clients.sortCreated'), value: 'createdAt' },
34
+ { label: t('features.clients.sortStatus'), value: 'status' }
35
+ ])
36
+
37
+ const routeQuery = () => ({
38
+ ...(search.value.trim() ? { search: search.value.trim() } : {}),
39
+ ...(status.value !== 'active' ? { status: status.value } : {}),
40
+ ...(sortBy.value !== 'name' ? { sortBy: sortBy.value } : {}),
41
+ ...(sortDir.value !== 'asc' ? { sortDir: sortDir.value } : {}),
42
+ ...(page.value > 1 ? { page: String(page.value) } : {})
43
+ })
44
+ const requestQuery = () => ({
45
+ ...routeQuery(),
46
+ status: status.value === 'all' ? undefined : status.value
47
+ })
48
+
49
+ const listReturnPath = computed(() => {
50
+ const query = new URLSearchParams({
51
+ ...routeQuery(),
52
+ ...(listScrollTop.value > 0 ? { scroll: String(Math.round(listScrollTop.value)) } : {})
53
+ })
54
+ const suffix = query.toString()
55
+ return suffix ? `/clients?${suffix}` : '/clients'
56
+ })
57
+
58
+ const clientDetailTo = (client: GenericClientDto) => ({
59
+ path: `/clients/${client.id}`,
60
+ query: { returnTo: listReturnPath.value }
61
+ })
62
+
63
+ const load = async () => {
64
+ pending.value = true
65
+ try {
66
+ result.value = await api.list({ ...requestQuery(), page: page.value, pageSize: 20 })
67
+ } finally {
68
+ pending.value = false
69
+ }
70
+ }
71
+
72
+ const restoreScroll = async () => {
73
+ if (!import.meta.client || scrollRestored.value) {
74
+ return
75
+ }
76
+ const value = Number(route.query.scroll)
77
+ if (!Number.isFinite(value) || value <= 0) {
78
+ return
79
+ }
80
+ await nextTick()
81
+ if (!listContainer.value) {
82
+ return
83
+ }
84
+ listContainer.value.scrollTop = value
85
+ scrollRestored.value = true
86
+ }
87
+
88
+ const syncAndLoad = async (resetPage = false) => {
89
+ if (resetPage) {
90
+ page.value = 1
91
+ }
92
+ scrollRestored.value = false
93
+ await router.replace({ path: route.path, query: routeQuery() })
94
+ await load()
95
+ }
96
+
97
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
98
+ watch(search, () => {
99
+ clearTimeout(searchTimer)
100
+ searchTimer = setTimeout(() => {
101
+ void syncAndLoad(true)
102
+ }, 300)
103
+ })
104
+ watch([status, sortBy, sortDir], () => {
105
+ void syncAndLoad(true)
106
+ })
107
+ watch(pending, (value) => {
108
+ if (!value) {
109
+ void restoreScroll()
110
+ }
111
+ })
112
+ onScopeDispose(() => clearTimeout(searchTimer))
113
+ onKeyStroke('Escape', () => {
114
+ formOpen.value = false
115
+ })
116
+
117
+ const save = async (input: Record<string, unknown>) => {
118
+ busy.value = true
119
+ try {
120
+ const client = await api.create({ ...input, moduleIds: defaultModules })
121
+ formOpen.value = false
122
+ await load()
123
+ toast.add({ title: t('features.clients.saved'), color: 'success' })
124
+ await navigateTo(clientDetailTo(client))
125
+ } catch (error) {
126
+ toast.add({ title: t('features.clients.saveFailed'), description: String(error), color: 'error' })
127
+ } finally {
128
+ busy.value = false
129
+ }
130
+ }
131
+
132
+ const goToPage = async (value: number) => {
133
+ page.value = value
134
+ listScrollTop.value = 0
135
+ await syncAndLoad()
136
+ listContainer.value?.scrollTo({ top: 0, behavior: 'smooth' })
137
+ }
138
+
139
+ await load()
140
+ onMounted(() => {
141
+ void restoreScroll()
142
+ })
143
+ </script>
144
+
145
+ <template>
146
+ <div class="flex h-full min-h-0 flex-col">
147
+ <div
148
+ ref="listContainer"
149
+ class="min-h-0 flex-1 overflow-y-auto"
150
+ @scroll="listScrollTop = listContainer?.scrollTop ?? 0"
151
+ >
152
+ <div class="mx-auto flex max-w-[1440px] flex-col gap-4 p-4 sm:p-6 lg:p-8">
153
+ <header class="flex items-center justify-between gap-3 border-b border-default pb-4 sm:items-end">
154
+ <div class="flex min-w-0 gap-3">
155
+ <UIcon name="i-lucide-building-2" class="mt-1 size-6 shrink-0 text-primary" />
156
+ <div class="min-w-0">
157
+ <h1 class="text-2xl font-semibold">{{ t('features.clients.title') }}</h1>
158
+ <p class="hidden text-sm text-muted sm:block">{{ t('features.clients.description') }}</p>
159
+ </div>
160
+ </div>
161
+ <div class="flex shrink-0 items-center gap-1">
162
+ <UButton
163
+ v-if="clients.length && !formOpen"
164
+ class="rounded-full sm:hidden"
165
+ icon="i-lucide-plus"
166
+ :aria-label="t('features.clients.new')"
167
+ @click="formOpen = true"
168
+ />
169
+ <UButton
170
+ v-if="clients.length && !formOpen"
171
+ class="hidden sm:inline-flex"
172
+ size="sm"
173
+ variant="outline"
174
+ icon="i-lucide-plus"
175
+ @click="formOpen = true"
176
+ >
177
+ {{ t('features.clients.new') }}
178
+ </UButton>
179
+ <UButton
180
+ icon="i-lucide-refresh-cw"
181
+ color="neutral"
182
+ variant="ghost"
183
+ size="sm"
184
+ :loading="pending"
185
+ :aria-label="t('common.refresh')"
186
+ @click="load"
187
+ />
188
+ </div>
189
+ </header>
190
+
191
+ <PortalListToolbar
192
+ v-model:search="search"
193
+ :search-placeholder="t('features.clients.search')"
194
+ :filters="[{ key: 'status', placeholder: t('features.clients.status'), items: statusOptions }]"
195
+ :filter-values="{ status }"
196
+ :sort-options="sortOptions"
197
+ :sort-by="sortBy"
198
+ :sort-dir="sortDir"
199
+ @filter="(_key, value) => (status = value || 'all')"
200
+ @sort="sortBy = $event"
201
+ @toggle-direction="sortDir = sortDir === 'asc' ? 'desc' : 'asc'"
202
+ />
203
+
204
+ <ClientsClientForm v-if="formOpen" :busy="busy" @submit="save" @cancel="formOpen = false" />
205
+
206
+ <UCard v-if="!clients.length && !pending && !formOpen" variant="subtle">
207
+ <div class="flex flex-col items-center gap-3 py-10 text-center">
208
+ <UIcon name="i-lucide-building-2" class="size-10 text-muted" />
209
+ <div>
210
+ <h2 class="font-semibold">{{ t('features.clients.emptyTitle') }}</h2>
211
+ <p class="text-sm text-muted">{{ t('features.clients.emptyDescription') }}</p>
212
+ </div>
213
+ <UButton icon="i-lucide-plus" @click="formOpen = true">{{ t('features.clients.createFirst') }}</UButton>
214
+ </div>
215
+ </UCard>
216
+
217
+ <div v-else class="grid gap-3">
218
+ <UCard
219
+ v-for="client in clients"
220
+ :key="client.id"
221
+ class="transition-colors hover:ring-1 hover:ring-primary/50"
222
+ >
223
+ <div class="grid grid-cols-[minmax(0,1fr)_2.75rem] items-center gap-3">
224
+ <NuxtLink
225
+ :to="clientDetailTo(client)"
226
+ class="group flex min-w-0 items-center gap-3 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
227
+ >
228
+ <UAvatar :src="client.avatarLogo || client.logo || undefined" :alt="client.name" />
229
+ <div class="min-w-0 flex-1">
230
+ <div class="flex flex-wrap items-center gap-2">
231
+ <p class="truncate font-semibold">{{ client.name }}</p>
232
+ <UBadge :color="client.archivedAt ? 'neutral' : 'success'" variant="subtle">{{
233
+ t(client.archivedAt ? 'features.clients.archived' : 'features.clients.active')
234
+ }}</UBadge>
235
+ </div>
236
+ <p class="mt-1 truncate text-sm text-muted">{{ client.officialName }} · {{ client.slug }}</p>
237
+ </div>
238
+ </NuxtLink>
239
+ <NuxtLink
240
+ :to="clientDetailTo(client)"
241
+ :aria-label="t('features.clients.openClient', { name: client.name })"
242
+ class="grid size-11 place-items-center justify-self-end rounded focus-visible:outline-2 focus-visible:outline-primary"
243
+ >
244
+ <UIcon name="i-lucide-chevron-right" class="size-5 text-muted" />
245
+ </NuxtLink>
246
+ </div>
247
+ </UCard>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ <footer
252
+ v-if="result"
253
+ class="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 border-t border-default px-4 pt-3 text-sm text-muted sm:px-6 lg:px-8"
254
+ >
255
+ <span>{{ t('features.clients.resultCount', result.pagination.totalItems) }}</span>
256
+ <UPagination
257
+ v-if="result.pagination.totalPages > 1"
258
+ :page="page"
259
+ :total="result.pagination.totalItems"
260
+ :items-per-page="20"
261
+ @update:page="goToPage"
262
+ />
263
+ </footer>
264
+ </div>
265
+ </template>
@@ -0,0 +1,3 @@
1
+ import { clientsFeature } from '@nuxt-customer-portal/clients/shared/feature'
2
+
3
+ export default defineNuxtPlugin(() => usePortalFeatures().registerFeature(clientsFeature))
@@ -0,0 +1,74 @@
1
+ {
2
+ "features": {
3
+ "clients": {
4
+ "title": "Clients",
5
+ "description": "Manage client companies, members, profiles, and module access.",
6
+ "backToClients": "Back to clients",
7
+ "openClient": "Open {name}",
8
+ "loading": "Loading client",
9
+ "notFound": "Client not found",
10
+ "profile": "Client details",
11
+ "lifecycle": "Status and deletion",
12
+ "new": "New client",
13
+ "create": "Create client",
14
+ "createFirst": "Create your first client",
15
+ "createTitle": "Create client",
16
+ "editTitle": "Edit client",
17
+ "edit": "Edit client",
18
+ "delete": "Delete client",
19
+ "deleteTitle": "Delete {name}",
20
+ "deleteDescription": "This permanently deletes the client. Type its exact name to continue.",
21
+ "deleteBlocked": "This client cannot be deleted while it has {members} members or {modules} module records. Archive it instead.",
22
+ "typeName": "Type “{name}” to confirm",
23
+ "close": "Close",
24
+ "cancel": "Cancel",
25
+ "save": "Save changes",
26
+ "saved": "Client saved",
27
+ "saveFailed": "Could not save the client",
28
+ "deleted": "Client deleted",
29
+ "name": "Display name",
30
+ "slug": "Slug",
31
+ "officialName": "Official company name",
32
+ "address": "Address",
33
+ "registrationNumber": "Registration number",
34
+ "vatNumber": "VAT number",
35
+ "invoiceEmail": "Email address",
36
+ "status": "Status",
37
+ "locale": "Preferred language",
38
+ "members": "Members and contacts",
39
+ "memberEmail": "Member email",
40
+ "invite": "Invite",
41
+ "invited": "Invitation sent",
42
+ "inviteFailed": "The invitation could not be sent. Check the email address and try again.",
43
+ "pendingInvitations": "Pending invitations",
44
+ "noPendingInvitations": "No pending invitations.",
45
+ "invitationPending": "Pending",
46
+ "invitationExpires": "Expires {date}",
47
+ "removeMember": "Remove member",
48
+ "confirmRemoveMember": "Remove {name} ({email}) as a member of {client}? This user will lose access to this client. Their user account will remain.",
49
+ "memberRemoved": "Member removed from client",
50
+ "removeMemberFailed": "Could not remove the member. Refresh the page and try again.",
51
+ "noMembers": "This client does not have members yet.",
52
+ "modules": "Modules",
53
+ "archive": "Archive client",
54
+ "restore": "Restore client",
55
+ "active": "Active",
56
+ "archived": "Archived",
57
+ "all": "All statuses",
58
+ "search": "Search clients",
59
+ "sortName": "Name",
60
+ "sortCreated": "Created",
61
+ "sortStatus": "Status",
62
+ "direction": "Toggle sort direction",
63
+ "emptyTitle": "No clients yet",
64
+ "emptyDescription": "Create a client before linking projects, time, invoices, or service requests.",
65
+ "resultCount": "{count} Client | {count} Clients",
66
+ "validation": {
67
+ "name": "Enter at least two characters.",
68
+ "slug": "Use lowercase letters, numbers, and single hyphens only.",
69
+ "officialName": "Enter the official company name.",
70
+ "email": "Enter a valid email address."
71
+ }
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,74 @@
1
+ {
2
+ "features": {
3
+ "clients": {
4
+ "title": "Klanten",
5
+ "description": "Beheer klantbedrijven, leden, profielen en moduletoegang.",
6
+ "backToClients": "Terug naar klanten",
7
+ "openClient": "Open {name}",
8
+ "loading": "Klant laden",
9
+ "notFound": "Klant niet gevonden",
10
+ "profile": "Klantgegevens",
11
+ "lifecycle": "Status en verwijderen",
12
+ "new": "Nieuwe klant",
13
+ "create": "Klant aanmaken",
14
+ "createFirst": "Maak je eerste klant aan",
15
+ "createTitle": "Klant aanmaken",
16
+ "editTitle": "Klant bewerken",
17
+ "edit": "Klant bewerken",
18
+ "delete": "Klant verwijderen",
19
+ "deleteTitle": "{name} verwijderen",
20
+ "deleteDescription": "Hiermee verwijder je de klant permanent. Typ de exacte naam om door te gaan.",
21
+ "deleteBlocked": "Deze klant kan niet worden verwijderd zolang er {members} leden of {modules} moduleregistraties zijn. Archiveer de klant in plaats daarvan.",
22
+ "typeName": "Typ ‘{name}’ ter bevestiging",
23
+ "close": "Sluiten",
24
+ "cancel": "Annuleren",
25
+ "save": "Wijzigingen opslaan",
26
+ "saved": "Klant opgeslagen",
27
+ "saveFailed": "De klant kon niet worden opgeslagen",
28
+ "deleted": "Klant verwijderd",
29
+ "name": "Weergavenaam",
30
+ "slug": "Slug",
31
+ "officialName": "Officiële bedrijfsnaam",
32
+ "address": "Adres",
33
+ "registrationNumber": "Registratienummer",
34
+ "vatNumber": "Btw-nummer",
35
+ "invoiceEmail": "E-mailadres",
36
+ "status": "Status",
37
+ "locale": "Voorkeurstaal",
38
+ "members": "Leden en contactpersonen",
39
+ "memberEmail": "E-mailadres van lid",
40
+ "invite": "Uitnodigen",
41
+ "invited": "Uitnodiging verstuurd",
42
+ "inviteFailed": "De uitnodiging kon niet worden verstuurd. Controleer het e-mailadres en probeer opnieuw.",
43
+ "pendingInvitations": "Openstaande uitnodigingen",
44
+ "noPendingInvitations": "Geen openstaande uitnodigingen.",
45
+ "invitationPending": "Openstaand",
46
+ "invitationExpires": "Verloopt op {date}",
47
+ "removeMember": "Lid verwijderen",
48
+ "confirmRemoveMember": "Wil je {name} ({email}) verwijderen als lid van {client}? Deze gebruiker verliest toegang tot deze klant. Het gebruikersaccount zelf blijft bestaan.",
49
+ "memberRemoved": "Lid verwijderd van de klant",
50
+ "removeMemberFailed": "Het lid kon niet worden verwijderd. Vernieuw de pagina en probeer opnieuw.",
51
+ "noMembers": "Deze klant heeft nog geen leden.",
52
+ "modules": "Modules",
53
+ "archive": "Klant archiveren",
54
+ "restore": "Klant herstellen",
55
+ "active": "Actief",
56
+ "archived": "Gearchiveerd",
57
+ "all": "Alle statussen",
58
+ "search": "Klanten zoeken",
59
+ "sortName": "Naam",
60
+ "sortCreated": "Aangemaakt",
61
+ "sortStatus": "Status",
62
+ "direction": "Sorteerrichting wijzigen",
63
+ "emptyTitle": "Nog geen klanten",
64
+ "emptyDescription": "Maak een klant aan voordat je projecten, uren, facturen of serviceverzoeken koppelt.",
65
+ "resultCount": "{count} klant | {count} klanten",
66
+ "validation": {
67
+ "name": "Voer minimaal twee tekens in.",
68
+ "slug": "Gebruik alleen kleine letters, cijfers en enkele koppeltekens.",
69
+ "officialName": "Voer de officiële bedrijfsnaam in.",
70
+ "email": "Voer een geldig e-mailadres in."
71
+ }
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,33 @@
1
+ CREATE SCHEMA IF NOT EXISTS "clients";
2
+
3
+ CREATE TABLE IF NOT EXISTS "clients"."client_profile" (
4
+ "organization_id" text PRIMARY KEY REFERENCES "organization"("id") ON DELETE restrict,
5
+ "official_name" text NOT NULL,
6
+ "address" text DEFAULT '' NOT NULL,
7
+ "registration_number" text,
8
+ "vat_number" text,
9
+ "invoice_email" text,
10
+ "preferred_locale" text DEFAULT 'nl' NOT NULL,
11
+ "archived_at" timestamp,
12
+ "archived_by_id" text REFERENCES "user"("id") ON DELETE set null,
13
+ "created_at" timestamp DEFAULT now() NOT NULL,
14
+ "updated_at" timestamp DEFAULT now() NOT NULL
15
+ );
16
+
17
+ CREATE INDEX IF NOT EXISTS "client_profile_archived_idx" ON "clients"."client_profile" ("archived_at");
18
+
19
+ CREATE TABLE IF NOT EXISTS "clients"."client_module" (
20
+ "id" text PRIMARY KEY,
21
+ "organization_id" text NOT NULL REFERENCES "organization"("id") ON DELETE restrict,
22
+ "module_id" text NOT NULL,
23
+ "enabled" boolean DEFAULT true NOT NULL,
24
+ "enabled_at" timestamp DEFAULT now() NOT NULL,
25
+ "enabled_by_id" text REFERENCES "user"("id") ON DELETE set null,
26
+ "disabled_at" timestamp,
27
+ "disabled_by_id" text REFERENCES "user"("id") ON DELETE set null,
28
+ "created_at" timestamp DEFAULT now() NOT NULL,
29
+ "updated_at" timestamp DEFAULT now() NOT NULL
30
+ );
31
+
32
+ CREATE UNIQUE INDEX IF NOT EXISTS "client_module_org_module_uidx" ON "clients"."client_module" ("organization_id", "module_id");
33
+ CREATE INDEX IF NOT EXISTS "client_module_org_enabled_idx" ON "clients"."client_module" ("organization_id", "enabled");
package/nuxt.config.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ export default defineNuxtConfig({
4
+ $meta: { name: 'nuxt-customer-portal-clients' },
5
+ compatibilityDate: '2025-10-24',
6
+ modules: ['@nuxtjs/i18n'],
7
+ components: [{ path: fileURLToPath(new URL('./app/components', import.meta.url)), global: true }],
8
+ i18n: {
9
+ locales: [
10
+ { code: 'en', iso: 'en-US', name: 'English', file: 'en.json' },
11
+ { code: 'nl', iso: 'nl-NL', name: 'Nederlands', file: 'nl.json' }
12
+ ]
13
+ },
14
+ runtimeConfig: {
15
+ public: { clients: { defaultModules: [] as string[] } }
16
+ }
17
+ })
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@nuxt-customer-portal/clients",
3
+ "version": "0.3.0",
4
+ "license": "MIT",
5
+ "homepage": "https://nuxt-customer-portal.com",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ludulicious/nuxt-customer-portal.git"
9
+ },
10
+ "type": "module",
11
+ "main": "./nuxt.config.ts",
12
+ "files": [
13
+ "LICENSE",
14
+ "CHANGELOG.md",
15
+ "app",
16
+ "i18n",
17
+ "migrations",
18
+ "server",
19
+ "shared",
20
+ "nuxt.config.ts",
21
+ "portal.manifest.mjs",
22
+ "README.md"
23
+ ],
24
+ "exports": {
25
+ ".": "./nuxt.config.ts",
26
+ "./feature": "./shared/feature.ts",
27
+ "./types": "./shared/types/client.ts",
28
+ "./schema": "./server/db/schema/clients.ts",
29
+ "./portal-manifest": "./portal.manifest.mjs",
30
+ "./app/*": "./app/*.ts",
31
+ "./server/*": "./server/*.ts",
32
+ "./shared/*": "./shared/*.ts"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@nuxtjs/i18n": "^10.6.0",
39
+ "@types/pg": "8.15.5",
40
+ "drizzle-orm": "^0.45.2",
41
+ "nanoid": "^5.1.6",
42
+ "pg": "^8.22.0",
43
+ "zod": "^4.4.3",
44
+ "@nuxt-customer-portal/core": "^0.3.0",
45
+ "@nuxt-customer-portal/ui": "^0.3.0"
46
+ },
47
+ "peerDependencies": {
48
+ "nuxt": "^4.5.1",
49
+ "vue": "^3.5.0"
50
+ },
51
+ "devDependencies": {
52
+ "nuxt": "^4.5.1",
53
+ "vue": "^3.5.0"
54
+ }
55
+ }
@@ -0,0 +1,8 @@
1
+ export default {
2
+ id: 'clients',
3
+ version: '0.3.0',
4
+ source: '@nuxt-customer-portal/clients',
5
+ dependsOn: ['core', 'ui'],
6
+ schema: './server/db/schema/clients.ts',
7
+ migrations: './migrations'
8
+ }
@@ -0,0 +1,11 @@
1
+ import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { getClient, setClientArchived } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+ import { clientArchiveSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const id = getRouterParam(event, 'id')!
7
+ const { session } = await requireOwnerClientManager(event)
8
+ const { archived } = clientArchiveSchema.parse(await readBody(event))
9
+ await setClientArchived(id, session.user.id, archived)
10
+ return getClient(id)
11
+ })
@@ -0,0 +1,7 @@
1
+ import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { getGenericClientDeletionEligibility } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ await requireOwnerClientManager(event)
6
+ return getGenericClientDeletionEligibility(getRouterParam(event, 'id')!)
7
+ })
@@ -0,0 +1,10 @@
1
+ import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { deleteGenericClient } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+ import { genericClientDeleteSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ await requireOwnerClientManager(event)
7
+ const { clientName } = genericClientDeleteSchema.parse(await readBody(event))
8
+ await deleteGenericClient(getRouterParam(event, 'id')!, clientName)
9
+ return { success: true }
10
+ })
@@ -0,0 +1,12 @@
1
+ import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { getClient } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const id = getRouterParam(event, 'id')!
6
+ await requireClientProfileManager(event, id)
7
+ const selected = await getClient(id)
8
+ if (!selected) {
9
+ throw createError({ statusCode: 404, message: 'Client not found' })
10
+ }
11
+ return selected
12
+ })
@@ -0,0 +1,10 @@
1
+ import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
2
+ import { getClient, updateClient } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+ import { clientUpdateSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const id = getRouterParam(event, 'id')!
7
+ await requireClientProfileManager(event, id)
8
+ await updateClient(id, clientUpdateSchema.parse(await readBody(event)))
9
+ return getClient(id)
10
+ })
@@ -0,0 +1,8 @@
1
+ import { changePendingInvitation } from '@nuxt-customer-portal/core/server/utils/invitation-management'
2
+ import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const organizationId = getRouterParam(event, 'id')!
6
+ await requireClientProfileManager(event, organizationId)
7
+ return changePendingInvitation(organizationId, getRouterParam(event, 'invitationId')!, await readBody(event))
8
+ })