@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.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/app/components/Admin/RequestDashboard.vue +64 -0
- package/app/components/Admin/RequestTable.vue +158 -0
- package/app/components/CustomerRequestDetail.vue +76 -0
- package/app/components/CustomerRequestForm.vue +143 -0
- package/app/components/ServiceRequestsDashboardAttention.vue +55 -0
- package/app/components/ServiceRequestsDashboardOverview.vue +55 -0
- package/app/components/StatusBadge.vue +16 -0
- package/app/composables/useAdminServiceRequests.ts +60 -0
- package/app/composables/usePaginatedServiceRequests.ts +21 -0
- package/app/composables/useServiceRequestAccess.ts +18 -0
- package/app/composables/useServiceRequestMenu.ts +34 -0
- package/app/composables/useServiceRequests.ts +134 -0
- package/app/composables/useServiceRequestsDashboard.ts +13 -0
- package/app/pages/admin/requests/[id].vue +140 -0
- package/app/pages/admin/requests/index.vue +55 -0
- package/app/pages/requests/[id].vue +161 -0
- package/app/pages/requests/index.vue +873 -0
- package/app/pages/requests/new.vue +37 -0
- package/app/plugins/service-requests-feature.ts +18 -0
- package/i18n/locales/en.json +97 -0
- package/i18n/locales/nl.json +97 -0
- package/migrations/0000_baseline.sql +30 -0
- package/migrations/0001_required_client.sql +6 -0
- package/migrations/meta/0000_snapshot.json +245 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +29 -0
- package/package.json +53 -0
- package/portal.manifest.mjs +9 -0
- package/server/api/service-requests/[id].delete.ts +42 -0
- package/server/api/service-requests/[id].get.ts +39 -0
- package/server/api/service-requests/[id].patch.ts +44 -0
- package/server/api/service-requests/admin/[id].patch.ts +43 -0
- package/server/api/service-requests/admin/index.get.ts +19 -0
- package/server/api/service-requests/dashboard.get.ts +11 -0
- package/server/api/service-requests/index.get.ts +22 -0
- package/server/api/service-requests/index.post.ts +39 -0
- package/server/db/schema/service-requests.ts +78 -0
- package/server/plugins/openapi-contracts.ts +22 -0
- package/server/utils/service-request-repository.ts +185 -0
- package/server/utils/service-request-scope.ts +41 -0
- package/server/utils/service-request-validation.ts +54 -0
- package/shared/feature.ts +90 -0
- 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>
|