@nuxt-customer-portal/service-requests 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 (46) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/app/components/Admin/RequestDashboard.vue +64 -0
  5. package/app/components/Admin/RequestTable.vue +158 -0
  6. package/app/components/CustomerRequestDetail.vue +76 -0
  7. package/app/components/CustomerRequestForm.vue +143 -0
  8. package/app/components/ServiceRequestsDashboardAttention.vue +55 -0
  9. package/app/components/ServiceRequestsDashboardOverview.vue +55 -0
  10. package/app/components/StatusBadge.vue +16 -0
  11. package/app/composables/useAdminServiceRequests.ts +60 -0
  12. package/app/composables/usePaginatedServiceRequests.ts +21 -0
  13. package/app/composables/useServiceRequestAccess.ts +18 -0
  14. package/app/composables/useServiceRequestMenu.ts +34 -0
  15. package/app/composables/useServiceRequests.ts +134 -0
  16. package/app/composables/useServiceRequestsDashboard.ts +13 -0
  17. package/app/pages/admin/requests/[id].vue +140 -0
  18. package/app/pages/admin/requests/index.vue +55 -0
  19. package/app/pages/requests/[id].vue +161 -0
  20. package/app/pages/requests/index.vue +873 -0
  21. package/app/pages/requests/new.vue +37 -0
  22. package/app/plugins/service-requests-feature.ts +18 -0
  23. package/i18n/locales/en.json +97 -0
  24. package/i18n/locales/nl.json +97 -0
  25. package/migrations/0000_baseline.sql +30 -0
  26. package/migrations/0001_required_client.sql +6 -0
  27. package/migrations/meta/0000_snapshot.json +245 -0
  28. package/migrations/meta/_journal.json +13 -0
  29. package/nuxt.config.ts +29 -0
  30. package/package.json +53 -0
  31. package/portal.manifest.mjs +9 -0
  32. package/server/api/service-requests/[id].delete.ts +42 -0
  33. package/server/api/service-requests/[id].get.ts +39 -0
  34. package/server/api/service-requests/[id].patch.ts +44 -0
  35. package/server/api/service-requests/admin/[id].patch.ts +43 -0
  36. package/server/api/service-requests/admin/index.get.ts +19 -0
  37. package/server/api/service-requests/dashboard.get.ts +11 -0
  38. package/server/api/service-requests/index.get.ts +22 -0
  39. package/server/api/service-requests/index.post.ts +39 -0
  40. package/server/db/schema/service-requests.ts +78 -0
  41. package/server/plugins/openapi-contracts.ts +22 -0
  42. package/server/utils/service-request-repository.ts +185 -0
  43. package/server/utils/service-request-scope.ts +41 -0
  44. package/server/utils/service-request-validation.ts +54 -0
  45. package/shared/feature.ts +90 -0
  46. package/shared/types/service-request.ts +111 -0
@@ -0,0 +1,42 @@
1
+ import { authorize, hasFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { serviceRequestFeature } from '@nuxt-customer-portal/service-requests/shared/feature'
3
+ import {
4
+ deleteServiceRequest,
5
+ findServiceRequest
6
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
7
+ import {
8
+ canAccessScopedRequest,
9
+ requireServiceRequestScope
10
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
11
+
12
+ defineRouteMeta({
13
+ openAPI: {
14
+ tags: ['Service Requests'],
15
+ operationId: 'serviceRequestsByIdDelete',
16
+ summary: 'Delete a service request',
17
+ description:
18
+ 'Delete a service request. Scoped to the active organization and the applicable Service Requests permission.'
19
+ }
20
+ })
21
+
22
+ export default defineEventHandler(async (event) => {
23
+ const scope = await requireServiceRequestScope(event, 'read')
24
+ const id = getRouterParam(event, 'id')
25
+ if (!id) {
26
+ throw createError({ statusCode: 400, message: 'Request id is required' })
27
+ }
28
+ const existing = await findServiceRequest(id)
29
+ if (!existing || !canAccessScopedRequest(existing, scope)) {
30
+ throw createError({ statusCode: 404, message: 'Request not found' })
31
+ }
32
+
33
+ const isOwner = existing.createdById === scope.session.user.id
34
+ if (
35
+ !isOwner &&
36
+ !(await hasFeatureAccess(scope.session, scope.organizationId, serviceRequestFeature.policy, 'delete'))
37
+ ) {
38
+ await authorize(scope.session, scope.organizationId, serviceRequestFeature.policy, 'delete')
39
+ }
40
+ await deleteServiceRequest(id)
41
+ return { success: true }
42
+ })
@@ -0,0 +1,39 @@
1
+ import { hasFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { serviceRequestFeature } from '@nuxt-customer-portal/service-requests/shared/feature'
3
+ import {
4
+ findServiceRequest,
5
+ toServiceRequestDto
6
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
7
+ import {
8
+ canAccessScopedRequest,
9
+ requireServiceRequestScope
10
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
11
+
12
+ defineRouteMeta({
13
+ openAPI: {
14
+ tags: ['Service Requests'],
15
+ operationId: 'serviceRequestsByIdGet',
16
+ summary: 'Get a service request',
17
+ description:
18
+ 'Get a service request. Scoped to the active organization and the applicable Service Requests permission.'
19
+ }
20
+ })
21
+
22
+ export default defineEventHandler(async (event) => {
23
+ const scope = await requireServiceRequestScope(event, 'read')
24
+ const id = getRouterParam(event, 'id')
25
+ if (!id) {
26
+ throw createError({ statusCode: 400, message: 'Request id is required' })
27
+ }
28
+
29
+ const row = await findServiceRequest(id)
30
+ if (!row || !canAccessScopedRequest(row, scope)) {
31
+ throw createError({ statusCode: 404, message: 'Request not found' })
32
+ }
33
+
34
+ const dto = toServiceRequestDto(row)
35
+ if (!(await hasFeatureAccess(scope.session, scope.organizationId, serviceRequestFeature.policy, 'manage'))) {
36
+ dto.internalNotes = null
37
+ }
38
+ return dto
39
+ })
@@ -0,0 +1,44 @@
1
+ import {
2
+ findServiceRequest,
3
+ toServiceRequestDto,
4
+ updateServiceRequest
5
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
6
+ import { updateServiceRequestSchema } from '@nuxt-customer-portal/service-requests/server/utils/service-request-validation'
7
+ import {
8
+ canAccessScopedRequest,
9
+ requireServiceRequestScope
10
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
11
+
12
+ defineRouteMeta({
13
+ openAPI: {
14
+ tags: ['Service Requests'],
15
+ operationId: 'serviceRequestsByIdPatch',
16
+ summary: 'Update a service request',
17
+ description:
18
+ 'Update a service request. Scoped to the active organization and the applicable Service Requests permission.'
19
+ }
20
+ })
21
+
22
+ export default defineEventHandler(async (event) => {
23
+ const scope = await requireServiceRequestScope(event, 'update')
24
+ const id = getRouterParam(event, 'id')
25
+ if (!id) {
26
+ throw createError({ statusCode: 400, message: 'Request id is required' })
27
+ }
28
+ const existing = await findServiceRequest(id)
29
+ if (!existing || !canAccessScopedRequest(existing, scope)) {
30
+ throw createError({ statusCode: 404, message: 'Request not found' })
31
+ }
32
+
33
+ const data = updateServiceRequestSchema.parse(await readBody(event))
34
+ const row = await updateServiceRequest(id, {
35
+ title: data.title,
36
+ description: data.description,
37
+ priority: data.priority,
38
+ category: data.category
39
+ })
40
+ if (!row) {
41
+ throw createError({ statusCode: 404, message: 'Request not found' })
42
+ }
43
+ return toServiceRequestDto(row)
44
+ })
@@ -0,0 +1,43 @@
1
+ import {
2
+ findServiceRequest,
3
+ toServiceRequestDto,
4
+ updateServiceRequest
5
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
6
+ import { adminUpdateServiceRequestSchema } from '@nuxt-customer-portal/service-requests/server/utils/service-request-validation'
7
+ import {
8
+ canAccessScopedRequest,
9
+ requireServiceRequestScope
10
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
11
+
12
+ defineRouteMeta({
13
+ openAPI: {
14
+ tags: ['Service Requests'],
15
+ operationId: 'serviceRequestsAdminByIdPatch',
16
+ summary: 'Update a service request as an administrator',
17
+ description:
18
+ 'Update a service request as an administrator. Scoped to the active organization and the applicable Service Requests permission.'
19
+ }
20
+ })
21
+
22
+ export default defineEventHandler(async (event) => {
23
+ const scope = await requireServiceRequestScope(event, 'manage')
24
+ const id = getRouterParam(event, 'id')
25
+ if (!id) {
26
+ throw createError({ statusCode: 400, message: 'Request id is required' })
27
+ }
28
+ const existing = await findServiceRequest(id)
29
+ if (!existing || !canAccessScopedRequest(existing, scope)) {
30
+ throw createError({ statusCode: 404, message: 'Request not found' })
31
+ }
32
+
33
+ const data = adminUpdateServiceRequestSchema.parse(await readBody(event))
34
+ const row = await updateServiceRequest(id, {
35
+ ...data,
36
+ resolvedAt: data.status === 'RESOLVED' ? new Date() : existing.resolvedAt,
37
+ closedAt: data.status === 'CLOSED' ? new Date() : existing.closedAt
38
+ })
39
+ if (!row) {
40
+ throw createError({ statusCode: 404, message: 'Request not found' })
41
+ }
42
+ return toServiceRequestDto(row)
43
+ })
@@ -0,0 +1,19 @@
1
+ import { requireServiceRequestScope } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
2
+ import { listServiceRequests } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
3
+ import { filterServiceRequestSchema } from '@nuxt-customer-portal/service-requests/server/utils/service-request-validation'
4
+
5
+ defineRouteMeta({
6
+ openAPI: {
7
+ tags: ['Service Requests'],
8
+ operationId: 'serviceRequestsAdminGet',
9
+ summary: 'List service requests for administration',
10
+ description:
11
+ 'List service requests for administration. Scoped to the active organization and the applicable Service Requests permission.'
12
+ }
13
+ })
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const scope = await requireServiceRequestScope(event, 'manage')
17
+ const filters = filterServiceRequestSchema.parse(getQuery(event))
18
+ return listServiceRequests(scope.providerOrganizationId, filters)
19
+ })
@@ -0,0 +1,11 @@
1
+ import { getServiceRequestDashboard } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
2
+ import { requireServiceRequestScope } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const scope = await requireServiceRequestScope(event, 'read')
6
+ const canManage = scope.organizationType === 'PROVIDER' && (scope.role === 'owner' || scope.role === 'admin')
7
+ return getServiceRequestDashboard(scope.providerOrganizationId, canManage, {
8
+ clientOrganizationId: scope.clientOrganizationId,
9
+ createdById: scope.ownOnly ? scope.session.user.id : undefined
10
+ })
11
+ })
@@ -0,0 +1,22 @@
1
+ import { requireServiceRequestScope } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
2
+ import { filterServiceRequestSchema } from '@nuxt-customer-portal/service-requests/server/utils/service-request-validation'
3
+ import { listServiceRequests } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
4
+
5
+ defineRouteMeta({
6
+ openAPI: {
7
+ tags: ['Service Requests'],
8
+ operationId: 'serviceRequestsGet',
9
+ summary: 'List service requests',
10
+ description:
11
+ 'List service requests. Scoped to the active organization and the applicable Service Requests permission.'
12
+ }
13
+ })
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const scope = await requireServiceRequestScope(event, 'list')
17
+ const filters = filterServiceRequestSchema.parse(getQuery(event))
18
+ return listServiceRequests(scope.providerOrganizationId, filters, {
19
+ clientOrganizationId: scope.clientOrganizationId,
20
+ createdById: scope.ownOnly ? scope.session.user.id : undefined
21
+ })
22
+ })
@@ -0,0 +1,39 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { requireClientModuleEnabled } from '@nuxt-customer-portal/clients/server/utils/client-repository'
3
+ import { requireServiceRequestScope } from '@nuxt-customer-portal/service-requests/server/utils/service-request-scope'
4
+ import { createServiceRequestSchema } from '@nuxt-customer-portal/service-requests/server/utils/service-request-validation'
5
+ import {
6
+ createServiceRequest,
7
+ toServiceRequestDto
8
+ } from '@nuxt-customer-portal/service-requests/server/utils/service-request-repository'
9
+
10
+ defineRouteMeta({
11
+ openAPI: {
12
+ tags: ['Service Requests'],
13
+ operationId: 'serviceRequestsPost',
14
+ summary: 'Create a service request',
15
+ description:
16
+ 'Create a service request. Scoped to the active organization and the applicable Service Requests permission.'
17
+ }
18
+ })
19
+
20
+ export default defineEventHandler(async (event) => {
21
+ const scope = await requireServiceRequestScope(event, 'create')
22
+ const data = createServiceRequestSchema.parse(await readBody(event))
23
+ const clientOrganizationId = scope.clientOrganizationId ?? data.clientOrganizationId
24
+ if (!clientOrganizationId) {
25
+ throw createError({ statusCode: 400, message: 'Client is required' })
26
+ }
27
+ await requireClientModuleEnabled(clientOrganizationId, 'service-requests')
28
+ const row = await createServiceRequest({
29
+ id: nanoid(),
30
+ ...data,
31
+ organizationId: scope.providerOrganizationId,
32
+ clientOrganizationId,
33
+ createdById: scope.session.user.id
34
+ })
35
+ if (!row) {
36
+ throw createError({ statusCode: 500, message: 'Failed to create request' })
37
+ }
38
+ return toServiceRequestDto(row)
39
+ })
@@ -0,0 +1,78 @@
1
+ import { pgSchema, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'
2
+ import { organization, user } from '@nuxt-customer-portal/core/schema'
3
+
4
+ export const serviceRequestsSchema = pgSchema('service_requests')
5
+
6
+ export interface ServiceRequestAttachmentRecord {
7
+ id: string
8
+ url: string
9
+ filename: string
10
+ size: number
11
+ mimeType: string
12
+ uploadedAt: string
13
+ uploadedById?: string
14
+ }
15
+
16
+ // Enums
17
+ export const serviceRequestStatus = serviceRequestsSchema.enum('ServiceRequestStatus', [
18
+ 'OPEN',
19
+ 'IN_PROGRESS',
20
+ 'RESOLVED',
21
+ 'CLOSED'
22
+ ])
23
+
24
+ export const serviceRequestPriority = serviceRequestsSchema.enum('ServiceRequestPriority', [
25
+ 'LOW',
26
+ 'MEDIUM',
27
+ 'HIGH',
28
+ 'URGENT'
29
+ ])
30
+
31
+ // Table
32
+ export const serviceRequest = serviceRequestsSchema.table(
33
+ 'service_request',
34
+ {
35
+ id: text('id').primaryKey(),
36
+ title: text('title').notNull(),
37
+ description: text('description').notNull(),
38
+ status: serviceRequestStatus('status').default('OPEN').notNull(),
39
+ priority: serviceRequestPriority('priority').default('MEDIUM').notNull(),
40
+ category: text('category'),
41
+
42
+ // Relations (FKs by convention; define explicit FKs in migrations if desired)
43
+ organizationId: text('organization_id')
44
+ .notNull()
45
+ .references(() => organization.id, { onDelete: 'cascade' }),
46
+ clientOrganizationId: text('client_organization_id')
47
+ .notNull()
48
+ .references(() => organization.id, { onDelete: 'restrict' }),
49
+ createdById: text('created_by_id')
50
+ .notNull()
51
+ .references(() => user.id, { onDelete: 'restrict' }),
52
+ assignedToId: text('assigned_to_id').references(() => user.id, { onDelete: 'set null' }),
53
+
54
+ // Metadata
55
+ attachments: jsonb('attachments').$type<ServiceRequestAttachmentRecord[]>(),
56
+ internalNotes: text('internal_notes'),
57
+
58
+ // Timestamps
59
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
60
+ updatedAt: timestamp('updated_at', { mode: 'date' })
61
+ .defaultNow()
62
+ .$onUpdate(() => new Date())
63
+ .notNull(),
64
+ resolvedAt: timestamp('resolved_at', { mode: 'date' }),
65
+ closedAt: timestamp('closed_at', { mode: 'date' })
66
+ },
67
+ (table) => [
68
+ index('service_request_organization_id_idx').on(table.organizationId),
69
+ index('service_request_client_organization_id_idx').on(table.clientOrganizationId),
70
+ index('service_request_created_by_id_idx').on(table.createdById),
71
+ index('service_request_assigned_to_id_idx').on(table.assignedToId),
72
+ index('service_request_status_idx').on(table.status),
73
+ index('service_request_created_at_idx').on(table.createdAt)
74
+ ]
75
+ )
76
+
77
+ export type ServiceRequestRecord = typeof serviceRequest.$inferSelect
78
+ export type NewServiceRequestRecord = typeof serviceRequest.$inferInsert
@@ -0,0 +1,22 @@
1
+ import { registerPortalOpenApiContracts } from '@nuxt-customer-portal/core/server/utils/openapi-contracts'
2
+ import {
3
+ adminUpdateServiceRequestSchema,
4
+ createServiceRequestSchema,
5
+ filterServiceRequestSchema,
6
+ updateServiceRequestSchema
7
+ } from '../utils/service-request-validation'
8
+
9
+ export default defineNitroPlugin(() => {
10
+ registerPortalOpenApiContracts({
11
+ owner: 'service-requests',
12
+ query: {
13
+ serviceRequestsGet: filterServiceRequestSchema,
14
+ serviceRequestsAdminGet: filterServiceRequestSchema
15
+ },
16
+ body: {
17
+ serviceRequestsPost: createServiceRequestSchema,
18
+ serviceRequestsByIdPatch: updateServiceRequestSchema,
19
+ serviceRequestsAdminByIdPatch: adminUpdateServiceRequestSchema
20
+ }
21
+ })
22
+ })
@@ -0,0 +1,185 @@
1
+ import { and, asc, desc, eq, ilike, or, sql, type SQL } from 'drizzle-orm'
2
+ import { db } from '@nuxt-customer-portal/core/server/portal'
3
+ import {
4
+ serviceRequest,
5
+ type NewServiceRequestRecord,
6
+ type ServiceRequestRecord
7
+ } from '@nuxt-customer-portal/service-requests/server/db/schema/service-requests'
8
+ import { organization } from '@nuxt-customer-portal/core/schema'
9
+ import { alias } from 'drizzle-orm/pg-core'
10
+ import type {
11
+ ServiceRequestDashboardDto,
12
+ ServiceRequestDto,
13
+ ServiceRequestListResponse
14
+ } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
15
+ import type { ServiceRequestQuery } from './service-request-validation'
16
+
17
+ const clientOrganization = alias(organization, 'service_request_client_organization')
18
+
19
+ export const toServiceRequestDto = (row: ServiceRequestRecord, clientName?: string): ServiceRequestDto => ({
20
+ id: row.id,
21
+ title: row.title,
22
+ description: row.description,
23
+ status: row.status,
24
+ priority: row.priority,
25
+ category: row.category,
26
+ organizationId: row.organizationId,
27
+ clientOrganizationId: row.clientOrganizationId,
28
+ ...(clientName ? { clientName } : {}),
29
+ createdById: row.createdById,
30
+ assignedToId: row.assignedToId,
31
+ attachments: row.attachments ?? [],
32
+ internalNotes: row.internalNotes,
33
+ createdAt: row.createdAt.toISOString(),
34
+ updatedAt: row.updatedAt.toISOString(),
35
+ resolvedAt: row.resolvedAt?.toISOString() ?? null,
36
+ closedAt: row.closedAt?.toISOString() ?? null
37
+ })
38
+
39
+ const filterConditions = (
40
+ organizationId: string,
41
+ filters: ServiceRequestQuery,
42
+ scope?: { clientOrganizationId?: string; createdById?: string }
43
+ ): SQL[] => {
44
+ const conditions: SQL[] = [eq(serviceRequest.organizationId, organizationId)]
45
+ if (scope?.clientOrganizationId) {
46
+ conditions.push(eq(serviceRequest.clientOrganizationId, scope.clientOrganizationId))
47
+ }
48
+ if (scope?.createdById) {
49
+ conditions.push(eq(serviceRequest.createdById, scope.createdById))
50
+ }
51
+ if (filters.clientOrganizationId) {
52
+ conditions.push(eq(serviceRequest.clientOrganizationId, filters.clientOrganizationId))
53
+ }
54
+ if (filters.status) {
55
+ conditions.push(eq(serviceRequest.status, filters.status))
56
+ }
57
+ if (filters.priority) {
58
+ conditions.push(eq(serviceRequest.priority, filters.priority))
59
+ }
60
+ if (filters.category) {
61
+ conditions.push(eq(serviceRequest.category, filters.category))
62
+ }
63
+ if (filters.assignedToId) {
64
+ conditions.push(eq(serviceRequest.assignedToId, filters.assignedToId))
65
+ }
66
+ if (filters.createdById) {
67
+ conditions.push(eq(serviceRequest.createdById, filters.createdById))
68
+ }
69
+ if (filters.search) {
70
+ const search = or(
71
+ ilike(serviceRequest.title, `%${filters.search}%`),
72
+ ilike(serviceRequest.description, `%${filters.search}%`)
73
+ )
74
+ if (search) {
75
+ conditions.push(search)
76
+ }
77
+ }
78
+ return conditions
79
+ }
80
+
81
+ export const listServiceRequests = async (
82
+ organizationId: string,
83
+ filters: ServiceRequestQuery,
84
+ scope?: { clientOrganizationId?: string; createdById?: string }
85
+ ): Promise<ServiceRequestListResponse> => {
86
+ const where = and(...filterConditions(organizationId, filters, scope))
87
+ const priorityRank = sql<number>`case
88
+ when ${serviceRequest.priority} = 'URGENT' then 4
89
+ when ${serviceRequest.priority} = 'HIGH' then 3
90
+ when ${serviceRequest.priority} = 'MEDIUM' then 2
91
+ when ${serviceRequest.priority} = 'LOW' then 1
92
+ else 0 end`
93
+ const orderExpression =
94
+ filters.sortBy === 'status'
95
+ ? serviceRequest.status
96
+ : filters.sortBy === 'priority'
97
+ ? priorityRank
98
+ : serviceRequest.createdAt
99
+
100
+ const [rows, total] = await Promise.all([
101
+ db
102
+ .select({ request: serviceRequest, clientName: clientOrganization.name })
103
+ .from(serviceRequest)
104
+ .innerJoin(clientOrganization, eq(clientOrganization.id, serviceRequest.clientOrganizationId))
105
+ .where(where)
106
+ .orderBy(filters.sortDir === 'asc' ? asc(orderExpression) : desc(orderExpression), asc(serviceRequest.id))
107
+ .offset(filters.offset)
108
+ .limit(filters.pageSize),
109
+ db.$count(serviceRequest, where)
110
+ ])
111
+
112
+ return {
113
+ items: rows.map((row) => toServiceRequestDto(row.request, row.clientName)),
114
+ pagination: {
115
+ total,
116
+ page: filters.page,
117
+ pageSize: filters.pageSize,
118
+ pageCount: Math.ceil(total / filters.pageSize)
119
+ }
120
+ }
121
+ }
122
+
123
+ export const getServiceRequestDashboard = async (
124
+ organizationId: string,
125
+ canManage: boolean,
126
+ scope?: { clientOrganizationId?: string; createdById?: string }
127
+ ): Promise<ServiceRequestDashboardDto> => {
128
+ const rows = await db
129
+ .select({ request: serviceRequest, clientName: clientOrganization.name })
130
+ .from(serviceRequest)
131
+ .innerJoin(clientOrganization, eq(clientOrganization.id, serviceRequest.clientOrganizationId))
132
+ .where(
133
+ and(
134
+ ...filterConditions(
135
+ organizationId,
136
+ { page: 1, pageSize: 20, offset: 0, sortBy: 'createdAt', sortDir: 'desc' },
137
+ scope
138
+ )
139
+ )
140
+ )
141
+ .orderBy(desc(serviceRequest.updatedAt), asc(serviceRequest.id))
142
+ const records = rows.map((row) => row.request)
143
+ const names = new Map(rows.map((row) => [row.request.id, row.clientName]))
144
+ const active = records.filter((item) => item.status === 'OPEN' || item.status === 'IN_PROGRESS')
145
+ const longOpenBoundary = Date.now() - 7 * 24 * 60 * 60 * 1000
146
+ const priorityRank = { URGENT: 4, HIGH: 3, MEDIUM: 2, LOW: 1 } as const
147
+ const attentionItems = [...active].sort(
148
+ (left, right) =>
149
+ priorityRank[right.priority] - priorityRank[left.priority] ||
150
+ left.createdAt.getTime() - right.createdAt.getTime() ||
151
+ left.id.localeCompare(right.id)
152
+ )
153
+ return {
154
+ overview: {
155
+ activeCount: active.length,
156
+ resolvedCount: records.length - active.length,
157
+ recent: records.slice(0, 5).map((item) => toServiceRequestDto(item, names.get(item.id)))
158
+ },
159
+ ...(canManage && {
160
+ attention: {
161
+ urgentCount: active.filter((item) => item.priority === 'URGENT').length,
162
+ unassignedCount: active.filter((item) => !item.assignedToId).length,
163
+ longOpenCount: active.filter((item) => item.createdAt.getTime() < longOpenBoundary).length,
164
+ items: attentionItems.slice(0, 5).map((item) => toServiceRequestDto(item, names.get(item.id)))
165
+ }
166
+ })
167
+ }
168
+ }
169
+
170
+ export const findServiceRequest = async (id: string) => {
171
+ const [row] = await db.select().from(serviceRequest).where(eq(serviceRequest.id, id)).limit(1)
172
+ return row
173
+ }
174
+
175
+ export const createServiceRequest = async (values: NewServiceRequestRecord) => {
176
+ const [row] = await db.insert(serviceRequest).values(values).returning()
177
+ return row
178
+ }
179
+
180
+ export const updateServiceRequest = async (id: string, values: Partial<NewServiceRequestRecord>) => {
181
+ const [row] = await db.update(serviceRequest).set(values).where(eq(serviceRequest.id, id)).returning()
182
+ return row
183
+ }
184
+
185
+ export const deleteServiceRequest = (id: string) => db.delete(serviceRequest).where(eq(serviceRequest.id, id))
@@ -0,0 +1,41 @@
1
+ import type { H3Event } from 'h3'
2
+ import { eq } from 'drizzle-orm'
3
+ import { db, requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
4
+ import { organization } from '@nuxt-customer-portal/core/schema'
5
+ import { requireClientModuleEnabled } from '@nuxt-customer-portal/clients/server/utils/client-repository'
6
+ import { serviceRequestFeature, type ServiceRequestAction } from '@nuxt-customer-portal/service-requests/shared/feature'
7
+
8
+ export const requireServiceRequestScope = async (event: H3Event, action: ServiceRequestAction) => {
9
+ const context = await requireFeatureAccess(event, serviceRequestFeature.policy, action)
10
+ if (context.organizationType === 'PROVIDER') {
11
+ return {
12
+ ...context,
13
+ providerOrganizationId: context.organizationId,
14
+ clientOrganizationId: undefined,
15
+ ownOnly: false
16
+ }
17
+ }
18
+ await requireClientModuleEnabled(context.organizationId, 'service-requests')
19
+ const [provider] = await db
20
+ .select({ id: organization.id })
21
+ .from(organization)
22
+ .where(eq(organization.organizationType, 'PROVIDER'))
23
+ .limit(1)
24
+ if (!provider) {
25
+ throw createError({ statusCode: 409, message: 'PROVIDER organization is not configured' })
26
+ }
27
+ return {
28
+ ...context,
29
+ providerOrganizationId: provider.id,
30
+ clientOrganizationId: context.organizationId,
31
+ ownOnly: context.role === 'member'
32
+ }
33
+ }
34
+
35
+ export const canAccessScopedRequest = (
36
+ row: { organizationId: string; clientOrganizationId: string; createdById: string },
37
+ scope: Awaited<ReturnType<typeof requireServiceRequestScope>>
38
+ ) =>
39
+ row.organizationId === scope.providerOrganizationId &&
40
+ (!scope.clientOrganizationId || row.clientOrganizationId === scope.clientOrganizationId) &&
41
+ (!scope.ownOnly || row.createdById === scope.session.user.id)
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod'
2
+
3
+ export const createServiceRequestSchema = z.object({
4
+ clientOrganizationId: z.string().min(1).max(128).optional(),
5
+ title: z.string().min(3).max(200),
6
+ description: z.string().min(10).max(5000),
7
+ priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(),
8
+ category: z.string().max(100).optional()
9
+ })
10
+
11
+ export const updateServiceRequestSchema = z.object({
12
+ title: z.string().min(3).max(200).optional(),
13
+ description: z.string().min(10).max(5000).optional(),
14
+ status: z.enum(['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']).optional(),
15
+ priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(),
16
+ category: z.string().max(100).optional()
17
+ })
18
+
19
+ export const adminUpdateServiceRequestSchema = updateServiceRequestSchema.extend({
20
+ assignedToId: z.string().optional(),
21
+ internalNotes: z.string().max(5000).optional()
22
+ })
23
+
24
+ export const filterServiceRequestSchema = z
25
+ .object({
26
+ clientOrganizationId: z.string().min(1).max(128).optional(),
27
+ status: z.enum(['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']).optional(),
28
+ priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(),
29
+ category: z.string().optional(),
30
+ assignedToId: z.string().optional(),
31
+ createdById: z.string().optional(),
32
+ search: z.string().optional(),
33
+ sortBy: z.enum(['createdAt', 'status', 'priority']).optional().default('createdAt'),
34
+ sortDir: z.enum(['asc', 'desc']).optional().default('desc'),
35
+ page: z.coerce.number().int().positive().optional(),
36
+ pageSize: z.coerce.number().int().positive().max(100).optional(),
37
+ skip: z.coerce.number().int().nonnegative().optional(),
38
+ take: z.coerce.number().int().positive().max(100).optional()
39
+ })
40
+ .transform((value) => {
41
+ const pageSize = value.pageSize ?? value.take ?? 20
42
+ const page = value.page ?? (value.skip === undefined ? 1 : Math.floor(value.skip / pageSize) + 1)
43
+ return {
44
+ ...value,
45
+ page,
46
+ pageSize,
47
+ offset: (page - 1) * pageSize
48
+ }
49
+ })
50
+
51
+ export type CreateServiceRequestInput = z.infer<typeof createServiceRequestSchema>
52
+ export type UpdateServiceRequestInput = z.infer<typeof updateServiceRequestSchema>
53
+ export type AdminUpdateServiceRequestInput = z.infer<typeof adminUpdateServiceRequestSchema>
54
+ export type ServiceRequestQuery = z.output<typeof filterServiceRequestSchema>