task-l.3.1
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cancelApproval, getApprovalRequest } from '@/features/foundation/approval/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -10,10 +14,15 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmApprovalRead
|
||||
});
|
||||
const approval = await getApprovalRequest(id, organization.id);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const approval = await getApprovalRequest(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -23,6 +32,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
if (error.status === 403) {
|
||||
const session = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmApprovalRead
|
||||
}).catch(() => null);
|
||||
|
||||
if (session) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: session.organization.id,
|
||||
userId: session.session.user.id,
|
||||
action: 'approval_scope_violation',
|
||||
entityType: 'crm_approval_request',
|
||||
entityId: (await params).id,
|
||||
reason: 'Approval detail visibility denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { listApprovalRequests, submitForApproval } from '@/features/foundation/approval/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -13,7 +17,7 @@ const submitApprovalSchema = z.object({
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmApprovalRead
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
@@ -24,6 +28,11 @@ export async function GET(request: NextRequest) {
|
||||
const entityType = searchParams.get('entityType') ?? undefined;
|
||||
const entityId = searchParams.get('entityId') ?? undefined;
|
||||
const sort = searchParams.get('sort') ?? undefined;
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const result = await listApprovalRequests(organization.id, {
|
||||
page,
|
||||
limit,
|
||||
@@ -32,7 +41,7 @@ export async function GET(request: NextRequest) {
|
||||
entityType,
|
||||
entityId,
|
||||
sort
|
||||
});
|
||||
}, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -45,6 +54,22 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
if (error.status === 403) {
|
||||
const session = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmApprovalRead
|
||||
}).catch(() => null);
|
||||
|
||||
if (session) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: session.organization.id,
|
||||
userId: session.session.user.id,
|
||||
action: 'approval_scope_violation',
|
||||
entityType: 'crm_approval_request',
|
||||
entityId: 'list',
|
||||
reason: 'Approval list visibility denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import {
|
||||
listCustomerContacts,
|
||||
softDeleteCustomerContact,
|
||||
@@ -17,11 +21,16 @@ type Params = {
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id, contactId } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactUpdate
|
||||
});
|
||||
const payload = customerContactSchema.parse(await request.json());
|
||||
const before = (await listCustomerContacts(id, organization.id)).find(
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = (await listCustomerContacts(id, organization.id, accessContext)).find(
|
||||
(contact) => contact.id === contactId
|
||||
);
|
||||
|
||||
@@ -34,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
contactId,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditUpdate({
|
||||
@@ -53,6 +63,22 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
if (error.status === 403) {
|
||||
const access = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactUpdate
|
||||
}).catch(() => null);
|
||||
|
||||
if (access) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: access.organization.id,
|
||||
userId: access.session.user.id,
|
||||
action: 'contact_scope_violation',
|
||||
entityType: 'crm_customer_contact',
|
||||
entityId: (await params).contactId,
|
||||
reason: 'Customer contact update visibility denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
@@ -74,10 +100,15 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id, contactId } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactDelete
|
||||
});
|
||||
const before = (await listCustomerContacts(id, organization.id)).find(
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = (await listCustomerContacts(id, organization.id, accessContext)).find(
|
||||
(contact) => contact.id === contactId
|
||||
);
|
||||
|
||||
@@ -89,7 +120,8 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
id,
|
||||
contactId,
|
||||
organization.id,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditDelete({
|
||||
@@ -107,6 +139,22 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
if (error.status === 403) {
|
||||
const access = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactDelete
|
||||
}).catch(() => null);
|
||||
|
||||
if (access) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: access.organization.id,
|
||||
userId: access.session.user.id,
|
||||
action: 'contact_scope_violation',
|
||||
entityType: 'crm_customer_contact',
|
||||
entityId: (await params).contactId,
|
||||
reason: 'Customer contact delete visibility denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import {
|
||||
createCustomerContact,
|
||||
listCustomerContacts
|
||||
@@ -16,10 +20,15 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactRead
|
||||
});
|
||||
const items = await listCustomerContacts(id, organization.id);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const items = await listCustomerContacts(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -29,6 +38,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
if (error.status === 403) {
|
||||
const access = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactRead
|
||||
}).catch(() => null);
|
||||
|
||||
if (access) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: access.organization.id,
|
||||
userId: access.session.user.id,
|
||||
action: 'contact_scope_violation',
|
||||
entityType: 'crm_customer_contact',
|
||||
entityId: (await params).id,
|
||||
reason: 'Customer contact list visibility denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
@@ -43,11 +68,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactCreate
|
||||
});
|
||||
const payload = customerContactSchema.parse(await request.json());
|
||||
const created = await createCustomerContact(id, organization.id, session.user.id, payload);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const created = await createCustomerContact(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import {
|
||||
getCustomerActivity,
|
||||
getCustomerDetail,
|
||||
@@ -25,11 +29,16 @@ const customerRequestSchema = customerSchema.extend({
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerRead
|
||||
});
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const [customer, activity] = await Promise.all([
|
||||
getCustomerDetail(id, organization.id),
|
||||
getCustomerDetail(id, organization.id, accessContext),
|
||||
getCustomerActivity(id, organization.id)
|
||||
]);
|
||||
|
||||
@@ -42,6 +51,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
if (error.status === 403) {
|
||||
const access = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerRead
|
||||
}).catch(() => null);
|
||||
|
||||
if (access) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: access.organization.id,
|
||||
userId: access.session.user.id,
|
||||
action: 'customer_scope_violation',
|
||||
entityType: 'crm_customer',
|
||||
entityId: (await params).id,
|
||||
reason: 'Customer detail visibility denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
@@ -54,12 +79,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerUpdate
|
||||
});
|
||||
const payload = customerRequestSchema.parse(await request.json());
|
||||
const before = await getCustomerDetail(id, organization.id);
|
||||
const updated = await updateCustomer(id, organization.id, session.user.id, payload);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getCustomerDetail(id, organization.id, accessContext);
|
||||
const updated = await updateCustomer(id, organization.id, session.user.id, payload, accessContext);
|
||||
|
||||
await auditUpdate({
|
||||
organizationId: organization.id,
|
||||
@@ -97,11 +127,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerDelete
|
||||
});
|
||||
const before = await getCustomerDetail(id, organization.id);
|
||||
const updated = await softDeleteCustomer(id, organization.id, session.user.id);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getCustomerDetail(id, organization.id, accessContext);
|
||||
const updated = await softDeleteCustomer(id, organization.id, session.user.id, accessContext);
|
||||
|
||||
await auditDelete({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { createCustomer, listCustomers } from '@/features/crm/customers/server/service';
|
||||
import { customerSchema } from '@/features/crm/customers/schemas/customer.schema';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
@@ -15,7 +19,7 @@ const customerRequestSchema = customerSchema.extend({
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerRead
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
@@ -26,6 +30,11 @@ export async function GET(request: NextRequest) {
|
||||
const customerType = searchParams.get('customerType') ?? undefined;
|
||||
const branch = searchParams.get('branch') ?? undefined;
|
||||
const sort = searchParams.get('sort') ?? undefined;
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const result = await listCustomers(organization.id, {
|
||||
page,
|
||||
limit,
|
||||
@@ -34,7 +43,7 @@ export async function GET(request: NextRequest) {
|
||||
customerType,
|
||||
branch,
|
||||
sort
|
||||
});
|
||||
}, accessContext);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -48,6 +57,22 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
if (error.status === 403) {
|
||||
const access = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerRead
|
||||
}).catch(() => null);
|
||||
|
||||
if (access) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: access.organization.id,
|
||||
userId: access.session.user.id,
|
||||
action: 'customer_scope_violation',
|
||||
entityType: 'crm_customer',
|
||||
entityId: 'list',
|
||||
reason: 'Customer list visibility denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { auth } from '@/auth';
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { ApprovalDetail } from '@/features/foundation/approval/components/approval-detail';
|
||||
import { getApprovalRequest } from '@/features/foundation/approval/server/service';
|
||||
import { approvalByIdOptions } from '@/features/foundation/approval/queries';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
@@ -40,9 +41,30 @@ export default async function ApprovalDetailRoute({ params }: PageProps) {
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit)));
|
||||
const isOrgAdmin =
|
||||
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
|
||||
const accessContext =
|
||||
session?.user?.activeOrganizationId && session?.user?.id
|
||||
? {
|
||||
organizationId: session.user.activeOrganizationId,
|
||||
userId: session.user.id,
|
||||
membershipRole: session.user.activeMembershipRole ?? 'user',
|
||||
businessRole: session.user.activeBusinessRole ?? 'sales_support',
|
||||
permissions: session.user.activePermissions ?? [],
|
||||
branchScopeIds: session.user.activeBranchScopeIds ?? [],
|
||||
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
|
||||
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
|
||||
branchScopeMode: 'all',
|
||||
productScopeMode: 'all'
|
||||
}
|
||||
: null;
|
||||
const resolvedCanRead =
|
||||
canRead && accessContext
|
||||
? await getApprovalRequest(id, session.user.activeOrganizationId!, accessContext)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canRead) {
|
||||
if (resolvedCanRead) {
|
||||
void queryClient.prefetchQuery(approvalByIdOptions(id));
|
||||
}
|
||||
|
||||
@@ -50,14 +72,14 @@ export default async function ApprovalDetailRoute({ params }: PageProps) {
|
||||
<PageContainer
|
||||
pageTitle='รายละเอียดการอนุมัติเอกสาร'
|
||||
pageDescription='ลำดับขั้น ผู้อนุมัติปัจจุบัน และไทม์ไลน์ของคำขออนุมัติเอกสาร'
|
||||
access={canRead}
|
||||
access={resolvedCanRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
You do not have access to this approval request.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{canRead && session?.user ? (
|
||||
{resolvedCanRead && session?.user ? (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ApprovalDetail
|
||||
approvalId={id}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { CustomerDetail } from '@/features/crm/customers/components/customer-detail';
|
||||
import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries';
|
||||
import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
|
||||
import {
|
||||
getCustomerDetail,
|
||||
getCustomerReferenceData
|
||||
} from '@/features/crm/customers/server/service';
|
||||
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service';
|
||||
import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
@@ -46,34 +49,55 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmContactDelete)));
|
||||
const accessContext =
|
||||
session?.user?.activeOrganizationId && session?.user?.id
|
||||
? {
|
||||
organizationId: session.user.activeOrganizationId,
|
||||
userId: session.user.id,
|
||||
membershipRole: session.user.activeMembershipRole ?? 'user',
|
||||
businessRole: session.user.activeBusinessRole ?? 'sales_support',
|
||||
permissions: session.user.activePermissions ?? [],
|
||||
branchScopeIds: session.user.activeBranchScopeIds ?? [],
|
||||
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
|
||||
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
|
||||
branchScopeMode: 'all',
|
||||
productScopeMode: 'all'
|
||||
}
|
||||
: null;
|
||||
const resolvedCanRead =
|
||||
canRead && accessContext
|
||||
? await getCustomerDetail(id, session.user.activeOrganizationId!, accessContext)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canRead) {
|
||||
if (resolvedCanRead) {
|
||||
void queryClient.prefetchQuery(customerByIdOptions(id));
|
||||
}
|
||||
|
||||
if (canContactRead) {
|
||||
if (resolvedCanRead && canContactRead) {
|
||||
void queryClient.prefetchQuery(customerContactsOptions(id));
|
||||
}
|
||||
|
||||
const referenceData =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
resolvedCanRead && session?.user?.activeOrganizationId
|
||||
? await getCustomerReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
const relatedEnquiries =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId)
|
||||
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
|
||||
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId, accessContext)
|
||||
: [];
|
||||
const relatedQuotations =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await listCustomerQuotationRelations(id, session.user.activeOrganizationId)
|
||||
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
|
||||
? await listCustomerQuotationRelations(id, session.user.activeOrganizationId, accessContext)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Customer Detail'
|
||||
pageDescription='Customer profile, contacts, audit timeline, and future CRM document handoff.'
|
||||
access={canRead}
|
||||
access={resolvedCanRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
You do not have access to this customer record.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema';
|
||||
import { crmCustomerContacts, crmCustomers, crmEnquiries, crmQuotations, msOptions, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { type CrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import type {
|
||||
BranchOption,
|
||||
CustomerActivityRecord,
|
||||
@@ -25,6 +27,8 @@ type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubG
|
||||
|
||||
let customerSubGroupColumnAvailable: boolean | undefined;
|
||||
|
||||
export type CustomerAccessContext = CrmSecurityContext;
|
||||
|
||||
const CUSTOMER_OPTION_CATEGORIES = {
|
||||
customerType: 'crm_customer_type',
|
||||
customerStatus: 'crm_customer_status',
|
||||
@@ -269,6 +273,130 @@ async function assertCustomerBelongsToOrganization(id: string, organizationId: s
|
||||
return customer;
|
||||
}
|
||||
|
||||
async function resolveAccessibleCustomerRelationSets(
|
||||
organizationId: string,
|
||||
accessContext: CustomerAccessContext
|
||||
) {
|
||||
const [enquiries, quotations] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
customerId: crmEnquiries.customerId,
|
||||
createdBy: crmEnquiries.createdBy,
|
||||
assignedToUserId: crmEnquiries.assignedToUserId,
|
||||
branchId: crmEnquiries.branchId,
|
||||
productType: crmEnquiries.productType
|
||||
})
|
||||
.from(crmEnquiries)
|
||||
.where(
|
||||
and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))
|
||||
),
|
||||
db
|
||||
.select({
|
||||
customerId: crmQuotations.customerId,
|
||||
createdBy: crmQuotations.createdBy,
|
||||
salesmanId: crmQuotations.salesmanId,
|
||||
branchId: crmQuotations.branchId,
|
||||
quotationType: crmQuotations.quotationType
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt))
|
||||
)
|
||||
]);
|
||||
|
||||
const enquiryCustomerIds = new Set<string>();
|
||||
const quotationCustomerIds = new Set<string>();
|
||||
|
||||
for (const row of enquiries) {
|
||||
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasProductScopeAccess(accessContext, row.productType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
accessContext.membershipRole === 'admin' ||
|
||||
accessContext.ownershipScope === 'organization' ||
|
||||
accessContext.ownershipScope === 'team' ||
|
||||
accessContext.ownershipScope === 'monitor' ||
|
||||
row.createdBy === accessContext.userId ||
|
||||
row.assignedToUserId === accessContext.userId
|
||||
) {
|
||||
enquiryCustomerIds.add(row.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of quotations) {
|
||||
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasProductScopeAccess(accessContext, row.quotationType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
accessContext.membershipRole === 'admin' ||
|
||||
accessContext.ownershipScope === 'organization' ||
|
||||
accessContext.ownershipScope === 'team' ||
|
||||
row.createdBy === accessContext.userId ||
|
||||
row.salesmanId === accessContext.userId
|
||||
) {
|
||||
quotationCustomerIds.add(row.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
return { enquiryCustomerIds, quotationCustomerIds };
|
||||
}
|
||||
|
||||
async function canAccessCustomerRow(
|
||||
row: CustomerRecordSource,
|
||||
accessContext: CustomerAccessContext
|
||||
) {
|
||||
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
accessContext.membershipRole === 'admin' ||
|
||||
accessContext.ownershipScope === 'organization' ||
|
||||
accessContext.ownershipScope === 'team'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { enquiryCustomerIds, quotationCustomerIds } = await resolveAccessibleCustomerRelationSets(
|
||||
row.organizationId,
|
||||
accessContext
|
||||
);
|
||||
|
||||
if (accessContext.ownershipScope === 'monitor') {
|
||||
return enquiryCustomerIds.has(row.id);
|
||||
}
|
||||
|
||||
return (
|
||||
row.createdBy === accessContext.userId ||
|
||||
enquiryCustomerIds.has(row.id) ||
|
||||
quotationCustomerIds.has(row.id)
|
||||
);
|
||||
}
|
||||
|
||||
async function assertCustomerAccess(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
|
||||
if (accessContext && !(await canAccessCustomerRow(customer, accessContext))) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
async function assertContactBelongsToCustomer(
|
||||
contactId: string,
|
||||
customerId: string,
|
||||
@@ -406,7 +534,8 @@ export async function getCustomerReferenceData(
|
||||
|
||||
export async function listCustomers(
|
||||
organizationId: string,
|
||||
filters: CustomerFilters
|
||||
filters: CustomerFilters,
|
||||
accessContext?: CustomerAccessContext
|
||||
): Promise<{ items: CustomerListItem[]; totalItems: number }> {
|
||||
const includeCustomerSubGroup = await hasCustomerSubGroupColumn();
|
||||
const page = filters.page ?? 1;
|
||||
@@ -415,16 +544,22 @@ export async function listCustomers(
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where);
|
||||
const rows = await db
|
||||
.select(getCustomerRecordSelection(includeCustomerSubGroup))
|
||||
.from(crmCustomers)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
.orderBy(parseSort(filters.sort));
|
||||
|
||||
const customerIds = rows.map((row) => row.id);
|
||||
const scopedRows = accessContext
|
||||
? (
|
||||
await Promise.all(
|
||||
rows.map(async (row) => ((await canAccessCustomerRow(row, accessContext)) ? row : null))
|
||||
)
|
||||
).filter((row): row is CustomerRecordSource => row !== null)
|
||||
: rows;
|
||||
const pagedRows = scopedRows.slice(offset, offset + limit);
|
||||
|
||||
const customerIds = pagedRows.map((row) => row.id);
|
||||
const contactCounts = customerIds.length
|
||||
? await db
|
||||
.select({
|
||||
@@ -444,19 +579,20 @@ export async function listCustomers(
|
||||
const countMap = new Map(contactCounts.map((item) => [item.customerId, item.value]));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
items: pagedRows.map((row) => ({
|
||||
...mapCustomerRecord(row),
|
||||
contactCount: countMap.get(row.id) ?? 0
|
||||
})),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
totalItems: scopedRows.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCustomerDetail(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: CustomerAccessContext
|
||||
): Promise<CustomerRecord> {
|
||||
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
const customer = await assertCustomerAccess(id, organizationId, accessContext);
|
||||
return mapCustomerRecord(customer);
|
||||
}
|
||||
|
||||
@@ -542,10 +678,11 @@ export async function updateCustomer(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CustomerMutationPayload
|
||||
payload: CustomerMutationPayload,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await validateCustomerPayload(organizationId, payload);
|
||||
await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
await assertCustomerAccess(id, organizationId, accessContext);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmCustomers)
|
||||
@@ -580,8 +717,13 @@ export async function updateCustomer(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function softDeleteCustomer(id: string, organizationId: string, userId: string) {
|
||||
await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
export async function softDeleteCustomer(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerAccess(id, organizationId, accessContext);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -614,8 +756,12 @@ export async function softDeleteCustomer(id: string, organizationId: string, use
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function listCustomerContacts(id: string, organizationId: string) {
|
||||
await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
export async function listCustomerContacts(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerAccess(id, organizationId, accessContext);
|
||||
const rows = await db.query.crmCustomerContacts.findMany({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.customerId, id),
|
||||
@@ -625,16 +771,26 @@ export async function listCustomerContacts(id: string, organizationId: string) {
|
||||
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
||||
});
|
||||
|
||||
return rows.map(mapCustomerContactRecord);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
!accessContext ||
|
||||
row.createdBy === accessContext.userId ||
|
||||
accessContext.membershipRole === 'admin' ||
|
||||
accessContext.ownershipScope === 'organization' ||
|
||||
accessContext.ownershipScope === 'team'
|
||||
)
|
||||
.map(mapCustomerContactRecord);
|
||||
}
|
||||
|
||||
export async function createCustomerContact(
|
||||
customerId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CustomerContactMutationPayload
|
||||
payload: CustomerContactMutationPayload,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
||||
await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (payload.isPrimary) {
|
||||
@@ -683,9 +839,10 @@ export async function updateCustomerContact(
|
||||
contactId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CustomerContactMutationPayload
|
||||
payload: CustomerContactMutationPayload,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
||||
await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
@@ -732,9 +889,10 @@ export async function softDeleteCustomerContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
||||
await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
|
||||
@@ -686,7 +686,7 @@ async function buildApprovalAnalytics(
|
||||
page: 1,
|
||||
limit: 50,
|
||||
status: 'pending'
|
||||
});
|
||||
}, context);
|
||||
const todayStart = startOfDay(new Date());
|
||||
const todayEnd = endOfDay(new Date());
|
||||
const rows = await db
|
||||
|
||||
@@ -1348,7 +1348,8 @@ export async function softDeleteEnquiryFollowup(
|
||||
|
||||
export async function listCustomerEnquiryRelations(
|
||||
customerId: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryCustomerRelationItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -1363,14 +1364,16 @@ export async function listCustomerEnquiryRelations(
|
||||
.orderBy(desc(crmEnquiries.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
productType: row.productType,
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
return rows
|
||||
.filter((row) => !accessContext || canAccessEnquiryRow(row, accessContext))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
productType: row.productType,
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncEnquiryPipelineStageFromQuotationStatus(
|
||||
|
||||
@@ -2319,7 +2319,8 @@ export async function listEnquiryQuotationRelations(
|
||||
|
||||
export async function listCustomerQuotationRelations(
|
||||
customerId: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: QuotationAccessContext
|
||||
): Promise<QuotationRelationItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -2333,12 +2334,14 @@ export async function listCustomerQuotationRelations(
|
||||
)
|
||||
.orderBy(desc(crmQuotations.updatedAt));
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
status: row.status,
|
||||
quotationType: row.quotationType,
|
||||
totalAmount: row.totalAmount,
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
return rows
|
||||
.filter((row) => !accessContext || canAccessQuotationRow(row, accessContext))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
status: row.status,
|
||||
quotationType: row.quotationType,
|
||||
totalAmount: row.totalAmount,
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -89,7 +89,14 @@ export function canAccessScopedCrmRecord(
|
||||
export async function auditCrmSecurityEvent(input: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
action: 'access_denied' | 'scope_violation' | 'permission_violation' | 'pricing_hidden';
|
||||
action:
|
||||
| 'access_denied'
|
||||
| 'scope_violation'
|
||||
| 'permission_violation'
|
||||
| 'pricing_hidden'
|
||||
| 'customer_scope_violation'
|
||||
| 'contact_scope_violation'
|
||||
| 'approval_scope_violation';
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
reason: string;
|
||||
|
||||
@@ -15,6 +15,10 @@ import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
|
||||
import { type SystemRole } from '@/lib/auth/rbac';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
type CrmSecurityContext,
|
||||
canAccessScopedCrmRecord
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type {
|
||||
ApprovalActionRecord,
|
||||
@@ -49,6 +53,7 @@ const APPROVAL_ACTIONS = {
|
||||
} as const;
|
||||
|
||||
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
|
||||
export type ApprovalAccessContext = CrmSecurityContext;
|
||||
|
||||
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
|
||||
return {
|
||||
@@ -683,24 +688,61 @@ function buildApprovalFilters(organizationId: string, filters: ApprovalFilters):
|
||||
];
|
||||
}
|
||||
|
||||
function canCurrentActorAccessApproval(
|
||||
currentStep: ApprovalStepRecord | null,
|
||||
accessContext?: ApprovalAccessContext
|
||||
) {
|
||||
if (!accessContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
accessContext.membershipRole === 'admin' ||
|
||||
accessContext.ownershipScope === 'organization'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!currentStep) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
accessContext.businessRole === currentStep.roleCode ||
|
||||
(accessContext.businessRoles ?? []).includes(currentStep.roleCode)
|
||||
);
|
||||
}
|
||||
|
||||
function canAccessQuotationForApproval(
|
||||
quotation: typeof crmQuotations.$inferSelect | null | undefined,
|
||||
accessContext?: ApprovalAccessContext
|
||||
) {
|
||||
if (!quotation || !accessContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canAccessScopedCrmRecord(accessContext, {
|
||||
branchId: quotation.branchId,
|
||||
productType: quotation.quotationType,
|
||||
createdBy: quotation.createdBy,
|
||||
ownerUserId: quotation.salesmanId
|
||||
});
|
||||
}
|
||||
|
||||
export async function listApprovalRequests(
|
||||
organizationId: string,
|
||||
filters: ApprovalFilters
|
||||
filters: ApprovalFilters,
|
||||
accessContext?: ApprovalAccessContext
|
||||
): Promise<{ items: ApprovalListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildApprovalFilters(organizationId, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db.select({ value: count() }).from(crmApprovalRequests).where(where);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalRequests)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
.orderBy(parseSort(filters.sort));
|
||||
|
||||
const workflowIds = [...new Set(rows.map((row) => row.workflowId))];
|
||||
const requesterIds = [...new Set(rows.map((row) => row.requestedBy))];
|
||||
@@ -740,11 +782,37 @@ export async function listApprovalRequests(
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: rows.map((row) => {
|
||||
const quotationIds = rows
|
||||
.filter((row) => row.entityType === 'quotation')
|
||||
.map((row) => row.entityId);
|
||||
const quotations = quotationIds.length
|
||||
? await db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
inArray(crmQuotations.id, quotationIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const quotationMap = new Map(quotations.map((row) => [row.id, row]));
|
||||
|
||||
const visibleItems = rows
|
||||
.map((row) => {
|
||||
const workflow = workflowMap.get(row.workflowId);
|
||||
const currentStep = stepMap.get(`${row.workflowId}:${row.currentStep}`) ?? null;
|
||||
const entitySummary = entitySummaries.get(`${row.entityType}:${row.entityId}`) ?? null;
|
||||
const canAccess =
|
||||
!accessContext ||
|
||||
canCurrentActorAccessApproval(currentStep, accessContext) ||
|
||||
(row.entityType === 'quotation' &&
|
||||
canAccessQuotationForApproval(quotationMap.get(row.entityId), accessContext));
|
||||
|
||||
if (!canAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...mapRequestRecord(row),
|
||||
@@ -755,9 +823,14 @@ export async function listApprovalRequests(
|
||||
requestedByName: requesterMap.get(row.requestedBy) ?? null,
|
||||
entityCode: entitySummary?.entityCode ?? null,
|
||||
entityTitle: entitySummary?.entityTitle ?? null
|
||||
};
|
||||
}),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
} satisfies ApprovalListItem;
|
||||
})
|
||||
.filter((item): item is ApprovalListItem => item !== null);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return {
|
||||
items: visibleItems.slice(offset, offset + limit),
|
||||
totalItems: visibleItems.length
|
||||
};
|
||||
}
|
||||
|
||||
@@ -815,7 +888,8 @@ export async function getCurrentApprovalStep(
|
||||
|
||||
export async function getApprovalRequest(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: ApprovalAccessContext
|
||||
): Promise<ApprovalDetailRecord> {
|
||||
const requestRow = await assertApprovalRequest(id, organizationId);
|
||||
const [workflowRow] = await db
|
||||
@@ -838,6 +912,31 @@ export async function getApprovalRequest(
|
||||
const currentStep = steps.find((step) => step.stepNumber === requestRow.currentStep) ?? null;
|
||||
const entitySummary =
|
||||
entitySummaries.get(`${requestRow.entityType}:${requestRow.entityId}`) ?? null;
|
||||
const quotation =
|
||||
requestRow.entityType === 'quotation'
|
||||
? (
|
||||
await db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
eq(crmQuotations.id, requestRow.entityId),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)[0]
|
||||
: null;
|
||||
|
||||
const canAccess =
|
||||
!accessContext ||
|
||||
canCurrentActorAccessApproval(currentStep, accessContext) ||
|
||||
(requestRow.entityType === 'quotation' && canAccessQuotationForApproval(quotation, accessContext));
|
||||
|
||||
if (!canAccess) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return {
|
||||
request: mapRequestRecord(requestRow),
|
||||
|
||||
Reference in New Issue
Block a user