task-d5.5.1
This commit is contained in:
7
src/app/api/crm/opportunities/[id]/_schemas.ts
Normal file
7
src/app/api/crm/opportunities/[id]/_schemas.ts
Normal 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()
|
||||
});
|
||||
|
||||
83
src/app/api/crm/opportunities/[id]/assign/route.ts
Normal file
83
src/app/api/crm/opportunities/[id]/assign/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
50
src/app/api/crm/opportunities/[id]/customers/route.ts
Normal file
50
src/app/api/crm/opportunities/[id]/customers/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
120
src/app/api/crm/opportunities/[id]/followups/route.ts
Normal file
120
src/app/api/crm/opportunities/[id]/followups/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
63
src/app/api/crm/opportunities/[id]/mark-lost/route.ts
Normal file
63
src/app/api/crm/opportunities/[id]/mark-lost/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
63
src/app/api/crm/opportunities/[id]/mark-won/route.ts
Normal file
63
src/app/api/crm/opportunities/[id]/mark-won/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
142
src/app/api/crm/opportunities/[id]/po-attachments/route.ts
Normal file
142
src/app/api/crm/opportunities/[id]/po-attachments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
83
src/app/api/crm/opportunities/[id]/reassign/route.ts
Normal file
83
src/app/api/crm/opportunities/[id]/reassign/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
61
src/app/api/crm/opportunities/[id]/reopen/route.ts
Normal file
61
src/app/api/crm/opportunities/[id]/reopen/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
172
src/app/api/crm/opportunities/[id]/route.ts
Normal file
172
src/app/api/crm/opportunities/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user