This commit is contained in:
phaichayon
2026-06-22 15:49:31 +07:00
parent 1b901efc51
commit 3f28fde39f
36 changed files with 2602 additions and 56 deletions

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import { unshareCustomerContact } from '@/features/crm/customers/server/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; contactId: string; shareId: string }>;
};
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, contactId, shareId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactShareDelete
});
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const removed = await unshareCustomerContact(
id,
contactId,
shareId,
organization.id,
accessContext
);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_contact_share',
entityId: shareId,
action: 'unshare',
beforeData: {
customerId: id,
contactId,
sharedUser: removed.sharedToUserId
}
});
return NextResponse.json({
success: true,
message: 'Contact share removed successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error(
'DELETE /api/crm/customers/[id]/contacts/[contactId]/shares/[shareId] failed',
error
);
return NextResponse.json({ message: 'Unable to remove contact share' }, { status: 500 });
}
}

View File

@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
listCustomerContactShares,
shareCustomerContact
} from '@/features/crm/customers/server/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; contactId: string }>;
};
const shareSchema = z.object({
sharedToUserId: z.string().min(1, 'Shared user is required'),
remark: z.string().optional().nullable()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id, contactId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactShareRead
});
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const items = await listCustomerContactShares(
id,
contactId,
organization.id,
accessContext
);
return NextResponse.json({
success: true,
message: 'Contact shares loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('GET /api/crm/customers/[id]/contacts/[contactId]/shares failed', error);
return NextResponse.json({ message: 'Unable to load contact shares' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id, contactId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactShareCreate
});
const payload = shareSchema.parse(await request.json());
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const created = await shareCustomerContact(
id,
contactId,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_contact_share',
entityId: created.id,
action: 'share',
afterData: {
customerId: id,
contactId,
sharedUser: payload.sharedToUserId,
remark: payload.remark ?? null
}
});
return NextResponse.json(
{
success: true,
message: 'Contact shared successfully'
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
console.error('POST /api/crm/customers/[id]/contacts/[contactId]/shares failed', error);
return NextResponse.json({ message: 'Unable to share contact' }, { status: 500 });
}
}

View File

@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
assignCustomerOwner,
clearCustomerOwner,
getCustomerDetail
} from '@/features/crm/customers/server/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const ownerSchema = z.object({
ownerUserId: z.string().min(1, 'Owner user is required'),
remark: z.string().optional().nullable()
});
const clearOwnerSchema = z.object({
remark: z.string().optional().nullable()
});
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerOwnerManage
});
const payload = ownerSchema.parse(await request.json());
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = await getCustomerDetail(id, organization.id, accessContext);
const updated = await assignCustomerOwner(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_customer_owner',
entityId: id,
action: before.ownerUserId ? 'change_owner' : 'assign_owner',
beforeData: {
customerId: id,
oldOwnerUserId: before.ownerUserId
},
afterData: {
customerId: id,
newOwnerUserId: payload.ownerUserId,
remark: payload.remark ?? null
}
});
return NextResponse.json({
success: true,
message: 'Customer owner updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
console.error('PATCH /api/crm/customers/[id]/owner failed', error);
return NextResponse.json({ message: 'Unable to update customer owner' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerOwnerManage
});
const payload = clearOwnerSchema.parse(
request.method === 'DELETE' ? (await request.json().catch(() => ({}))) : {}
);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = await getCustomerDetail(id, organization.id, accessContext);
const updated = await clearCustomerOwner(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_customer_owner',
entityId: id,
action: 'clear_owner',
beforeData: {
customerId: id,
oldOwnerUserId: before.ownerUserId
},
afterData: {
customerId: id,
newOwnerUserId: null,
remark: payload.remark ?? null
}
});
return NextResponse.json({
success: true,
message: 'Customer owner cleared successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
console.error('DELETE /api/crm/customers/[id]/owner failed', error);
return NextResponse.json({ message: 'Unable to clear customer owner' }, { status: 500 });
}
}

View File

@@ -8,6 +8,7 @@ import {
import {
getCustomerActivity,
getCustomerDetail,
getCustomerOwnerHistory,
softDeleteCustomer,
updateCustomer
} from '@/features/crm/customers/server/service';
@@ -37,9 +38,10 @@ export async function GET(_request: NextRequest, { params }: Params) {
userId: session.user.id,
membership
});
const [customer, activity] = await Promise.all([
const [customer, activity, ownerHistory] = await Promise.all([
getCustomerDetail(id, organization.id, accessContext),
getCustomerActivity(id, organization.id)
getCustomerActivity(id, organization.id),
getCustomerOwnerHistory(id, organization.id, accessContext)
]);
return NextResponse.json({
@@ -47,7 +49,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
time: new Date().toISOString(),
message: 'Customer loaded successfully',
customer,
activity
activity,
ownerHistory
});
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -29,6 +29,8 @@ export async function GET(request: NextRequest) {
const customerStatus = searchParams.get('customerStatus') ?? undefined;
const customerType = searchParams.get('customerType') ?? undefined;
const branch = searchParams.get('branch') ?? undefined;
const ownerUserId = searchParams.get('ownerUserId') ?? undefined;
const myCustomers = searchParams.get('myCustomers') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
@@ -42,6 +44,8 @@ export async function GET(request: NextRequest) {
customerStatus,
customerType,
branch,
ownerUserId,
myCustomers,
sort
}, accessContext);
const offset = (page - 1) * limit;

View File

@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import { auditAction, auditCreate } from '@/features/foundation/audit-log/service';
import { createEnquiry, listEnquiries } from '@/features/crm/enquiries/server/service';
import { getCustomerDetail } from '@/features/crm/customers/server/service';
import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const enquiryRequestSchema = enquirySchema.extend({
contactId: z.string().nullable().optional(),
assignedToUserId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
@@ -102,6 +104,7 @@ export async function POST(request: NextRequest) {
accessContext,
payload
);
const customer = await getCustomerDetail(payload.customerId, organization.id);
await auditCreate({
organizationId: organization.id,
@@ -112,6 +115,25 @@ export async function POST(request: NextRequest) {
afterData: created
});
if (customer.ownerUserId) {
await auditAction({
organizationId: organization.id,
branchId: created.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityId: created.id,
action:
payload.assignedToUserId && payload.assignedToUserId !== customer.ownerUserId
? 'lead_owner_suggestion_overridden'
: 'lead_owner_suggestion_used',
afterData: {
customerId: payload.customerId,
suggestedOwnerUserId: customer.ownerUserId,
assignedToUserId: payload.assignedToUserId ?? customer.ownerUserId
}
});
}
return NextResponse.json(
{
success: true,