task-d5.5.1

This commit is contained in:
phaichayon
2026-06-25 12:16:41 +07:00
parent f2c7156851
commit c2a74b6764
121 changed files with 3407 additions and 2679 deletions

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const opportunityAssignmentRequestSchema = z.object({
assignedToUserId: z.string().min(1, 'Sales user is required'),
remark: z.string().trim().max(1000).optional()
});

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import { assignOpportunityToUser, getOpportunityDetail } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { opportunityAssignmentRequestSchema } from '../_schemas';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityAssign
});
const payload = opportunityAssignmentRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await assignOpportunityToUser(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_opportunity',
entityId: id,
action: 'lead_to_opportunity',
beforeData: {
oldAssignedToUserId: before.assignedToUserId,
oldPipelineStage: before.pipelineStage
},
afterData: {
newAssignedToUserId: updated.assignedToUserId,
newPipelineStage: updated.pipelineStage,
remark: updated.assignmentRemark
}
});
return NextResponse.json({
success: true,
message: 'Lead converted to opportunity successfully',
opportunity: updated
});
} 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to assign opportunity' }, { status: 500 });
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { listOpportunityProjectParties } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityRead
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listOpportunityProjectParties(id, organization.id, accessContext);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Opportunity project parties loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to load opportunity project parties' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
listOpportunityFollowups,
softDeleteOpportunityFollowup,
updateOpportunityFollowup
} from '@/features/crm/opportunities/server/service';
import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; followupId: string }>;
};
const followupRequestSchema = opportunityFollowupSchema.extend({
contactId: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityFollowupUpdate
});
const payload = followupRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listOpportunityFollowups(id, organization.id, accessContext)
).find(
(item) => item.id === followupId
);
if (!before) {
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
}
const updated = await updateOpportunityFollowup(
id,
followupId,
organization.id,
session.user.id,
payload,
accessContext
);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_opportunity_followup',
entityId: followupId,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Opportunity follow-up updated successfully',
followup: updated
});
} 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update opportunity follow-up' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityFollowupDelete
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listOpportunityFollowups(id, organization.id, accessContext)
).find(
(item) => item.id === followupId
);
if (!before) {
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
}
const updated = await softDeleteOpportunityFollowup(
id,
followupId,
organization.id,
session.user.id,
accessContext
);
await auditDelete({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_opportunity_followup',
entityId: followupId,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Opportunity follow-up deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete opportunity follow-up' }, { status: 500 });
}
}

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createOpportunityFollowup,
listOpportunityFollowups
} from '@/features/crm/opportunities/server/service';
import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const followupRequestSchema = opportunityFollowupSchema.extend({
contactId: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityFollowupRead
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listOpportunityFollowups(id, organization.id, accessContext);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Opportunity follow-ups loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load opportunity follow-ups' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityFollowupCreate
});
const payload = followupRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const created = await createOpportunityFollowup(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_opportunity_followup',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Opportunity follow-up created successfully',
followup: created
},
{ 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create opportunity follow-up' }, { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
opportunityMarkLostSchema
} from '@/features/crm/opportunities/schemas/opportunity.schema';
import { getOpportunityDetail, markOpportunityAsLost } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityMarkLost
});
const payload = opportunityMarkLostSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await markOpportunityAsLost(id, organization.id, session.user.id, payload, accessContext);
const after = await getOpportunityDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Opportunity marked as lost successfully',
before,
opportunity: updated,
after
});
} 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to mark opportunity as lost' }, { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
opportunityMarkWonSchema
} from '@/features/crm/opportunities/schemas/opportunity.schema';
import { getOpportunityDetail, markOpportunityAsWon } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityMarkWon
});
const payload = opportunityMarkWonSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await markOpportunityAsWon(id, organization.id, session.user.id, payload, accessContext);
const after = await getOpportunityDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Opportunity marked as won successfully',
before,
opportunity: updated,
after
});
} 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to mark opportunity as won' }, { status: 500 });
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { getOpportunityAttachment } from '@/features/crm/opportunities/server/service';
import { getStorageProvider } from '@/features/foundation/storage/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; attachmentId: string }>;
};
function buildAccessContext(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
};
}) {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
}
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id, attachmentId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityRead
});
if (
membership.role !== 'admin' &&
!(membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const attachment = await getOpportunityAttachment(
id,
attachmentId,
organization.id,
buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
);
const object = await getStorageProvider().getObject({ key: attachment.storageKey });
const download = request.nextUrl.searchParams.get('download') === '1';
return new NextResponse(object.body, {
headers: {
'Content-Type': attachment.fileType ?? object.contentType,
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${attachment.originalFileName}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load PO attachment' }, { status: 500 });
}
}

View File

@@ -0,0 +1,142 @@
import { NextRequest, NextResponse } from 'next/server';
import {
listOpportunityAttachments,
uploadPurchaseOrderAttachment
} from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
function buildAccessContext(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
};
}) {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
}
function canViewCommercialData(membership: { role: string; permissions?: string[] | null }) {
return (
membership.role === 'admin' ||
(membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
);
}
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityRead
});
if (!canViewCommercialData(membership)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const items = await listOpportunityAttachments(
id,
organization.id,
buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Opportunity purchase order attachments loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load PO attachments' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityMarkWon
});
if (!canViewCommercialData(membership)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ message: 'PO attachment file is required' }, { status: 400 });
}
const created = await uploadPurchaseOrderAttachment({
opportunityId: id,
organizationId: organization.id,
userId: session.user.id,
description:
typeof formData.get('description') === 'string' ? (formData.get('description') as string) : null,
file: {
name: file.name,
type: file.type,
size: file.size,
buffer: Buffer.from(await file.arrayBuffer())
},
accessContext: buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
});
return NextResponse.json({
success: true,
message: 'PO attachment uploaded successfully',
attachment: created
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 });
}
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getOpportunityDetail, reassignOpportunityToUser } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { opportunityAssignmentRequestSchema } from '../_schemas';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityReassign
});
const payload = opportunityAssignmentRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await reassignOpportunityToUser(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_opportunity',
entityId: id,
action: 'reassign',
beforeData: {
oldAssignedToUserId: before.assignedToUserId,
oldPipelineStage: before.pipelineStage
},
afterData: {
newAssignedToUserId: updated.assignedToUserId,
newPipelineStage: updated.pipelineStage,
remark: updated.assignmentRemark
}
});
return NextResponse.json({
success: true,
message: 'Opportunity reassigned successfully',
opportunity: updated
});
} 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to reassign opportunity' }, { status: 500 });
}
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { opportunityReopenSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { getOpportunityDetail, reopenLostOpportunity } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityReopen
});
const payload = opportunityReopenSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await reopenLostOpportunity(id, organization.id, session.user.id, payload, accessContext);
const after = await getOpportunityDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Opportunity reopened successfully',
before,
opportunity: updated,
after
});
} 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to reopen opportunity' }, { status: 500 });
}
}

View File

@@ -0,0 +1,172 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getOpportunityActivity,
getOpportunityDetail,
softDeleteOpportunity,
updateOpportunity
} from '@/features/crm/opportunities/server/service';
import { opportunitySchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const opportunityRequestSchema = opportunitySchema.extend({
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityRead
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const [opportunity, activity] = await Promise.all([
getOpportunityDetail(id, organization.id, accessContext),
getOpportunityActivity(id, organization.id, accessContext)
]);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Opportunity loaded successfully',
opportunity,
activity
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load opportunity' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityUpdate
});
const payload = opportunityRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await updateOpportunity(id, organization.id, session.user.id, payload, accessContext);
await auditUpdate({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_opportunity',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Opportunity updated successfully',
opportunity: updated
});
} 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 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update opportunity' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityDelete
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await softDeleteOpportunity(id, organization.id, session.user.id, accessContext);
await auditDelete({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_opportunity',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Opportunity deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete opportunity' }, { status: 500 });
}
}