diff --git a/docs/adr/0017-report-foundation.md b/docs/adr/0017-report-foundation.md new file mode 100644 index 0000000..ed1a2fe --- /dev/null +++ b/docs/adr/0017-report-foundation.md @@ -0,0 +1,48 @@ +# ADR 0017: CRM Report Foundation + +## Status + +Accepted + +## Context + +CRM now has stable foundations for: + +- access control and scope resolution +- customer ownership and contact sharing +- lead and enquiry lifecycle +- won/lost governance +- revenue attribution +- dashboard KPI + +Future report modules need one shared architecture so they do not re-implement: + +- report discovery +- filter contracts +- export helpers +- access resolution +- audit logging + +## Decision + +We introduce a dedicated report foundation under `src/features/crm/reports/`. + +The foundation includes: + +- `crm_report_definitions` as the report registry +- `crm_report_category` master options for grouping and future permissions +- shared filter metadata endpoint +- `ResolvedReportContext` for organization, scope, and permission resolution +- `resolveCrmAccess()` as the common context adapter for reports +- dataset and builder layers for report queries +- shared CSV/XLS export helpers +- `crm_report` audit entries for `view`, `export_csv`, and `export_excel` + +Reports must not query the database directly from UI routes. Route handlers call the report server layer instead. + +## Consequences + +- K.2-K.7 can add business reports without redefining report plumbing +- scope and permission enforcement stays aligned with CRM authorization work from Task L +- export behavior becomes reusable instead of dashboard-specific +- report navigation can be driven from the seeded registry and category metadata diff --git a/docs/implementation/task-k1-report-foundation.md b/docs/implementation/task-k1-report-foundation.md new file mode 100644 index 0000000..d034453 --- /dev/null +++ b/docs/implementation/task-k1-report-foundation.md @@ -0,0 +1,39 @@ +# Task K.1 Implementation Summary + +## Completed + +- added `crm_report_definitions` schema and migration +- seeded `crm_report_category` master options +- seeded system report definitions: + - `lead_pipeline` + - `enquiry_pipeline` + - `sales_performance` + - `lost_analysis` + - `revenue_by_customer` +- added permissions: + - `crm.report.read` + - `crm.report.export` +- created `src/features/crm/reports/` foundation with: + - API contracts + - shared query options + - `ResolvedReportContext` + - `resolveCrmAccess()` + - dataset layer + - builder layer + - shared filter metadata builder + - export helpers +- added report discovery APIs: + - `GET /api/crm/reports` + - `GET /api/crm/reports/catalog` + - `GET /api/crm/reports/filters` +- added audit logging for report foundation reads +- added CRM Reports landing page and sidebar navigation entry + +## Verification + +- `npm exec tsc --noEmit` + +## Notes + +- this task creates the shared report platform only; it does not implement business report execution pages yet +- export helpers are centralized and ready for future report-specific export routes diff --git a/drizzle/0018_report_foundation.sql b/drizzle/0018_report_foundation.sql new file mode 100644 index 0000000..1667c66 --- /dev/null +++ b/drizzle/0018_report_foundation.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "crm_report_definitions" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "description" text, + "category" text NOT NULL, + "is_system" boolean DEFAULT true NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "crm_report_definitions_org_code_idx" +ON "crm_report_definitions" ("organization_id", "code"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e618ec9..5f68729 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1782121929212, "tag": "0017_short_swordsman", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1782799200000, + "tag": "0018_report_foundation", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/plans/task-k.1.md b/plans/task-k.1.md new file mode 100644 index 0000000..009f052 --- /dev/null +++ b/plans/task-k.1.md @@ -0,0 +1,392 @@ +# Task K.1: CRM Report Foundation + +## Objective + +Create a reusable reporting foundation for all future CRM reports. + +This task does not focus on business reports themselves. + +Instead it builds: + +* report architecture +* report filters +* report security +* report exports +* report datasets +* report metadata registry + +for all future report modules. + +--- + +# Background + +Completed foundations: + +* Customer Ownership (C.1) +* Contact Sharing (C.1) +* Lead / Enquiry Lifecycle (D.3) +* Won / Lost Governance (D.4) +* Revenue Attribution (D.2.1) +* CRM Dashboard KPI (J) +* CRM Authorization (L) + +The data model is now stable enough to support reporting. + +--- + +# Scope K1.1 Report Registry + +Add: + +```txt +crm_report_definitions +``` + +Purpose: + +Store report metadata. + +Fields: + +```txt +id + +code +name + +description + +category + +isSystem +isActive + +createdAt +updatedAt +``` + +Example: + +lead_pipeline +enquiry_pipeline +sales_performance +lost_analysis +revenue_by_customer + +--- + +# Scope K1.2 Report Categories + +Seed: + +```txt +Pipeline +Sales +Revenue +Customer +Quotation +Approval +Outcome +Executive +``` + +Used for: + +* report navigation +* report grouping +* future permissions + +--- + +# Scope K1.3 Shared Report Filter Model + +Create reusable filter object. + +Support: + +```txt +Date Range + +Branch + +Product Type + +Sales + +Customer + +Customer Owner + +Lead Source + +Pipeline Stage + +Won/Lost + +Lost Reason +``` + +Reusable across all reports. + +--- + +# Scope K1.4 Report Query Context + +Create: + +```txt +ResolvedReportContext +``` + +Combines: + +```txt +Organization + +Branch Scope + +Product Scope + +Ownership Scope + +Permissions +``` + +Must reuse CRM authorization model. + +--- + +# Scope K1.5 Report Dataset Layer + +Create: + +```txt +src/features/crm/reports/server/ +``` + +Structure: + +```txt +datasets/ +builders/ +filters/ +exports/ +``` + +Purpose: + +Single source of truth for report queries. + +Reports must never query database directly from UI routes. + +--- + +# Scope K1.6 Report Security + +All reports must use: + +```txt +resolveCrmAccess() +``` + +and + +```txt +ResolvedReportContext +``` + +Security rules inherited from: + +* Task L +* Task L.1 +* Task L.2 +* Task L.3 +* Task L.3.1 + +--- + +# Scope K1.7 Report Export Foundation + +Create common export service. + +Supported: + +```txt +CSV +Excel +``` + +All reports share same export mechanism. + +--- + +# Scope K1.8 Report Audit Logging + +Entity: + +```txt +crm_report +``` + +Actions: + +```txt +view +export_csv +export_excel +``` + +Audit payload: + +```txt +reportCode +filters +userId +``` + +--- + +# Scope K1.9 Report Navigation + +Add: + +```txt +CRM + └─ Reports +``` + +Landing page only. + +Display: + +```txt +Pipeline Reports +Sales Reports +Revenue Reports +Customer Reports +Outcome Reports +Approval Reports +``` + +No business reports yet. + +Only report catalog. + +--- + +# Scope K1.10 Foundation APIs + +Add: + +```txt +GET /api/crm/reports + +GET /api/crm/reports/catalog + +GET /api/crm/reports/filters +``` + +Purpose: + +Report discovery. + +No heavy report execution yet. + +--- + +# Scope K1.11 Permissions + +Add: + +```txt +crm.report.read +crm.report.export +``` + +Future reports inherit these permissions. + +--- + +# Scope K1.12 Documentation + +Create: + +```txt +docs/adr/0017-report-foundation.md + +docs/implementation/task-k1-report-foundation.md +``` + +Document: + +* report architecture +* security model +* dataset model +* export model + +--- + +# Explicit Non-Scope + +Do NOT build: + +* Lead Report +* Sales Report +* Revenue Report +* Customer Report +* Executive Report + +Those belong to: + +```txt +K.2 +K.3 +K.4 +K.5 +K.6 +``` + +--- + +# Deliverables + +1. Report Registry +2. Report Catalog +3. Shared Filters +4. Dataset Layer +5. Export Foundation +6. Security Foundation +7. Report Navigation +8. Audit Logging +9. ADR 0017 + +--- + +# Definition of Done + +Task K.1 is complete when: + +* Reports menu exists +* Report catalog exists +* Shared filter model exists +* Dataset architecture exists +* Export foundation exists +* Security foundation exists +* Report permissions exist +* Report ADR exists + +Result: + +```txt +CRM Report Center Foundation = READY + +Ready for: + +K.2 Pipeline Reports +K.3 Outcome Analytics +K.4 Revenue Analytics +K.5 Sales Performance +K.6 Customer Analytics +K.7 Approval Analytics +``` diff --git a/src/app/api/crm/reports/catalog/route.ts b/src/app/api/crm/reports/catalog/route.ts new file mode 100644 index 0000000..8f036b3 --- /dev/null +++ b/src/app/api/crm/reports/catalog/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { resolveCrmAccess } from '@/features/crm/reports/server/context'; +import { getCrmReportCatalogResponse } from '@/features/crm/reports/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmReportRead + }); + + return NextResponse.json( + await getCrmReportCatalogResponse( + resolveCrmAccess({ + organizationId: organization.id, + userId: session.user.id, + membership + }) + ) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load CRM report catalog' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/reports/filters/route.ts b/src/app/api/crm/reports/filters/route.ts new file mode 100644 index 0000000..6fbf3d4 --- /dev/null +++ b/src/app/api/crm/reports/filters/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { resolveCrmAccess } from '@/features/crm/reports/server/context'; +import { getCrmReportFiltersResponse } from '@/features/crm/reports/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmReportRead + }); + + return NextResponse.json( + await getCrmReportFiltersResponse( + resolveCrmAccess({ + organizationId: organization.id, + userId: session.user.id, + membership + }) + ) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load CRM report filters' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/reports/route.ts b/src/app/api/crm/reports/route.ts new file mode 100644 index 0000000..8af1562 --- /dev/null +++ b/src/app/api/crm/reports/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getCrmReportsResponse } from '@/features/crm/reports/server/service'; +import { resolveCrmAccess } from '@/features/crm/reports/server/context'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET() { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmReportRead + }); + + return NextResponse.json( + await getCrmReportsResponse( + resolveCrmAccess({ + organizationId: organization.id, + userId: session.user.id, + membership + }) + ) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load CRM reports' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/reports/page.tsx b/src/app/dashboard/crm/reports/page.tsx new file mode 100644 index 0000000..2aa70c3 --- /dev/null +++ b/src/app/dashboard/crm/reports/page.tsx @@ -0,0 +1,48 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { auth } from '@/auth'; +import PageContainer from '@/components/layout/page-container'; +import { + crmReportCatalogQueryOptions, + crmReportFiltersQueryOptions +} from '@/features/crm/reports/api/queries'; +import { ReportCatalog } from '@/features/crm/reports/components/report-catalog'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; + +export const metadata = { + title: 'CRM Reports' +}; + +export default async function CrmReportsRoute() { + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmReportRead))); + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(crmReportCatalogQueryOptions()); + void queryClient.prefetchQuery(crmReportFiltersQueryOptions()); + } + + return ( + + You do not have access to the CRM report center. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index c7e6782..92362fb 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -131,6 +131,14 @@ export const navGroups: NavGroup[] = [ items: [], access: { requireOrg: true, permission: "crm.approval.read" }, }, + { + title: "Reports", + url: "/dashboard/crm/reports", + icon: "dashboard", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.report.read" }, + }, { title: "ตั้งค่า CRM", url: "/dashboard/crm/settings/master-options", diff --git a/src/db/schema.ts b/src/db/schema.ts index 65b2af1..dc81c51 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -394,6 +394,28 @@ export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', { deletedAt: timestamp('deleted_at', { withTimezone: true }) }); +export const crmReportDefinitions = pgTable( + 'crm_report_definitions', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + code: text('code').notNull(), + name: text('name').notNull(), + description: text('description'), + category: text('category').notNull(), + isSystem: boolean('is_system').default(true).notNull(), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + organizationCodeIdx: uniqueIndex('crm_report_definitions_org_code_idx').on( + table.organizationId, + table.code + ) + }) +); + export const crmQuotations = pgTable( 'crm_quotations', { diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 2e04373..24d4a2c 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -38,6 +38,13 @@ type TemplateMappingSeed = { columns?: TemplateColumnSeed[]; }; +type ReportDefinitionSeed = { + code: string; + name: string; + description: string; + category: string; +}; + function loadEnvFile(filePath: string) { if (!fs.existsSync(filePath)) { return; @@ -334,9 +341,52 @@ const FOUNDATION_OPTIONS = { { code: 'facebook', label: 'Facebook', value: 'facebook', sortOrder: 4 }, { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } + ], + crm_report_category: [ + { code: 'pipeline', label: 'Pipeline', value: 'pipeline', sortOrder: 1 }, + { code: 'sales', label: 'Sales', value: 'sales', sortOrder: 2 }, + { code: 'revenue', label: 'Revenue', value: 'revenue', sortOrder: 3 }, + { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 4 }, + { code: 'quotation', label: 'Quotation', value: 'quotation', sortOrder: 5 }, + { code: 'approval', label: 'Approval', value: 'approval', sortOrder: 6 }, + { code: 'outcome', label: 'Outcome', value: 'outcome', sortOrder: 7 }, + { code: 'executive', label: 'Executive', value: 'executive', sortOrder: 8 } ] }; +const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [ + { + code: 'lead_pipeline', + name: 'Lead Pipeline', + description: 'Foundation catalog entry for future lead pipeline reporting.', + category: 'pipeline' + }, + { + code: 'enquiry_pipeline', + name: 'Enquiry Pipeline', + description: 'Foundation catalog entry for opportunity stage reporting.', + category: 'pipeline' + }, + { + code: 'sales_performance', + name: 'Sales Performance', + description: 'Foundation catalog entry for salesperson KPI and ranking reports.', + category: 'sales' + }, + { + code: 'lost_analysis', + name: 'Lost Analysis', + description: 'Foundation catalog entry for lost reason and competitor analysis.', + category: 'outcome' + }, + { + code: 'revenue_by_customer', + name: 'Revenue By Customer', + description: 'Foundation catalog entry for customer revenue attribution reports.', + category: 'revenue' + } +]; + const DOCUMENT_SEQUENCES = [ { documentType: 'customer', prefix: 'CUS' }, { documentType: 'contact', prefix: 'CON' }, @@ -972,6 +1022,43 @@ async function upsertDocumentTemplatesForOrganization( `; } +async function upsertReportDefinitionsForOrganization( + sql: SqlClient, + organization: { id: string } +) { + for (const definition of REPORT_DEFINITIONS) { + await sql` + insert into crm_report_definitions ( + id, + organization_id, + code, + name, + description, + category, + is_system, + is_active + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${definition.code}, + ${definition.name}, + ${definition.description}, + ${definition.category}, + ${true}, + ${true} + ) + on conflict (organization_id, code) do update + set + name = excluded.name, + description = excluded.description, + category = excluded.category, + is_system = excluded.is_system, + is_active = excluded.is_active, + updated_at = now() + `; + } +} + async function main() { loadLocalEnv(); @@ -996,6 +1083,7 @@ async function main() { await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); await upsertApprovalWorkflowsForOrganization(sql, organization.id); await upsertDocumentTemplatesForOrganization(sql, organization); + await upsertReportDefinitionsForOrganization(sql, organization); console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); } } finally { diff --git a/src/features/crm/customers/server/service.ts b/src/features/crm/customers/server/service.ts index 76ac180..f118795 100644 --- a/src/features/crm/customers/server/service.ts +++ b/src/features/crm/customers/server/service.ts @@ -1,4 +1,16 @@ -import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; +import { + and, + asc, + count, + desc, + eq, + ilike, + inArray, + isNull, + or, + sql, + type SQL, +} from "drizzle-orm"; import { crmContactShares, crmCustomerContacts, @@ -8,20 +20,26 @@ import { crmQuotations, memberships, 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'; + 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 { canAccessContact, canAccessCustomer, - type CrmSecurityContext -} from '@/features/crm/security/server/service'; + type CrmSecurityContext, +} from "@/features/crm/security/server/service"; import type { BranchOption, CustomerActivityRecord, @@ -34,10 +52,13 @@ import type { CustomerOwnerHistoryRecord, CustomerOwnerMutationPayload, CustomerRecord, - CustomerReferenceData -} from '../api/types'; + CustomerReferenceData, +} from "../api/types"; -type CustomerRecordSource = Omit & { +type CustomerRecordSource = Omit< + typeof crmCustomers.$inferSelect, + "customerSubGroup" +> & { customerSubGroup?: string | null; ownerName?: string | null; ownerAssignedByName?: string | null; @@ -48,43 +69,45 @@ let customerSubGroupColumnAvailable: boolean | undefined; export type CustomerAccessContext = CrmSecurityContext; const CUSTOMER_OPTION_CATEGORIES = { - customerType: 'crm_customer_type', - customerStatus: 'crm_customer_status', - leadChannel: 'crm_lead_channel', - customerGroup: 'crm_customer_group', - customerSubGroup: 'crm_customer_sub_group' + customerType: "crm_customer_type", + customerStatus: "crm_customer_status", + leadChannel: "crm_lead_channel", + customerGroup: "crm_customer_group", + customerSubGroup: "crm_customer_sub_group", } as const; function mapCustomerOption( - option: Awaited>[number] + option: Awaited>[number], ): CustomerOption { return { id: option.id, code: option.code, label: option.label, value: option.value, - parentId: option.parentId + parentId: option.parentId, }; } -function mapCustomerOptionRow(row: typeof msOptions.$inferSelect): CustomerOption { +function mapCustomerOptionRow( + row: typeof msOptions.$inferSelect, +): CustomerOption { return { id: row.id, code: row.code, label: row.label, value: row.value, - parentId: row.parentId + parentId: row.parentId, }; } function mapBranchOption( - branch: Awaited>[number] + branch: Awaited>[number], ): BranchOption { return { id: branch.id, code: branch.code, name: branch.name, - value: branch.value + value: branch.value, }; } @@ -97,8 +120,8 @@ function mapCustomerUserLookup(row: { return { id: row.userId, name: row.name, - membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user', - businessRole: row.businessRole + membershipRole: row.membershipRole === "admin" ? "admin" : "user", + businessRole: row.businessRole, } as const; } @@ -107,7 +130,7 @@ function mapOwnerHistoryRecord( oldOwnerName?: string | null; newOwnerName?: string | null; changedByName?: string | null; - } + }, ): CustomerOwnerHistoryRecord { return { id: row.id, @@ -119,10 +142,18 @@ function mapOwnerHistoryRecord( changedBy: row.changedBy, changedByName: row.changedByName ?? null, changedAt: row.changedAt.toISOString(), - remark: row.remark ?? null + remark: row.remark ?? null, }; } +function toIsoDateTime(value: Date | string) { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function toOptionalIsoDateTime(value: Date | string | null | undefined) { + return value ? toIsoDateTime(value) : null; +} + function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord { return { id: row.id, @@ -148,17 +179,17 @@ function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord { customerGroup: row.customerGroup, customerSubGroup: row.customerSubGroup ?? null, ownerUserId: row.ownerUserId ?? null, - ownerAssignedAt: row.ownerAssignedAt?.toISOString() ?? null, + ownerAssignedAt: toOptionalIsoDateTime(row.ownerAssignedAt), ownerAssignedBy: row.ownerAssignedBy ?? null, ownerName: row.ownerName ?? null, ownerAssignedByName: row.ownerAssignedByName ?? null, notes: row.notes, isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - deletedAt: row.deletedAt?.toISOString() ?? null, + createdAt: toIsoDateTime(row.createdAt), + updatedAt: toIsoDateTime(row.updatedAt), + deletedAt: toOptionalIsoDateTime(row.deletedAt), createdBy: row.createdBy, - updatedBy: row.updatedBy + updatedBy: row.updatedBy, }; } @@ -193,14 +224,14 @@ const baseCustomerRecordSelection = { updatedAt: crmCustomers.updatedAt, deletedAt: crmCustomers.deletedAt, createdBy: crmCustomers.createdBy, - updatedBy: crmCustomers.updatedBy + updatedBy: crmCustomers.updatedBy, } as const; function getCustomerRecordSelection(includeCustomerSubGroup: boolean) { return includeCustomerSubGroup ? { ...baseCustomerRecordSelection, - customerSubGroup: crmCustomers.customerSubGroup + customerSubGroup: crmCustomers.customerSubGroup, } : baseCustomerRecordSelection; } @@ -227,7 +258,7 @@ async function hasCustomerSubGroupColumn() { function mapCustomerContactRecord( row: typeof crmCustomerContacts.$inferSelect, - shares: CustomerContactRecord['shares'] = [] + shares: CustomerContactRecord["shares"] = [], ): CustomerContactRecord { return { id: row.id, @@ -247,7 +278,7 @@ function mapCustomerContactRecord( deletedAt: row.deletedAt?.toISOString() ?? null, createdBy: row.createdBy, updatedBy: row.updatedBy, - shares + shares, }; } @@ -270,7 +301,7 @@ function parseSort(sort?: string) { customerStatus: crmCustomers.customerStatus, customerType: crmCustomers.customerType, branch: crmCustomers.branchId, - updatedAt: crmCustomers.updatedAt + updatedAt: crmCustomers.updatedAt, } as const; const column = sortMap[rule.id as keyof typeof sortMap]; @@ -295,14 +326,16 @@ function splitFilterValue(value?: string) { } async function resolveValidOptionIds(category: string, organizationId: string) { - const options = await getActiveOptionsByCategory(category, { organizationId }); + const options = await getActiveOptionsByCategory(category, { + organizationId, + }); return new Set(options.map((option) => option.id)); } async function assertMasterOptionValue( organizationId: string, category: string, - optionId?: string | null + optionId?: string | null, ) { if (!optionId) { return; @@ -315,7 +348,10 @@ async function assertMasterOptionValue( } } -async function assertCustomerBelongsToOrganization(id: string, organizationId: string) { +async function assertCustomerBelongsToOrganization( + id: string, + organizationId: string, +) { const includeCustomerSubGroup = await hasCustomerSubGroupColumn(); const [customer] = await db .select(getCustomerRecordSelection(includeCustomerSubGroup)) @@ -324,13 +360,13 @@ async function assertCustomerBelongsToOrganization(id: string, organizationId: s and( eq(crmCustomers.id, id), eq(crmCustomers.organizationId, organizationId), - isNull(crmCustomers.deletedAt) - ) + isNull(crmCustomers.deletedAt), + ), ) .limit(1); if (!customer) { - throw new AuthError('Customer not found', 404); + throw new AuthError("Customer not found", 404); } return customer; @@ -342,33 +378,46 @@ async function listCrmUsersForOrganization(organizationId: string) { userId: memberships.userId, name: users.name, membershipRole: memberships.role, - businessRole: memberships.businessRole + businessRole: memberships.businessRole, }) .from(memberships) .innerJoin(users, eq(memberships.userId, users.id)) .where(eq(memberships.organizationId, organizationId)) .orderBy(asc(users.name)); - return [...new Map(rows.map((row) => [row.userId, row])).values()].map(mapCustomerUserLookup); + return [...new Map(rows.map((row) => [row.userId, row])).values()].map( + mapCustomerUserLookup, + ); } -async function assertOwnerUserBelongsToOrganization(ownerUserId: string, organizationId: string) { +async function assertOwnerUserBelongsToOrganization( + ownerUserId: string, + organizationId: string, +) { const [membership] = await db .select({ - userId: memberships.userId + userId: memberships.userId, }) .from(memberships) - .where(and(eq(memberships.userId, ownerUserId), eq(memberships.organizationId, organizationId))) + .where( + and( + eq(memberships.userId, ownerUserId), + eq(memberships.organizationId, organizationId), + ), + ) .limit(1); if (!membership) { - throw new AuthError('Owner user not found in organization', 404); + throw new AuthError("Owner user not found in organization", 404); } } -async function listContactSharesMap(contactIds: string[], organizationId: string) { +async function listContactSharesMap( + contactIds: string[], + organizationId: string, +) { if (!contactIds.length) { - return new Map(); + return new Map(); } const shareRows = await db @@ -380,7 +429,7 @@ async function listContactSharesMap(contactIds: string[], organizationId: string sharedByUserId: crmContactShares.sharedByUserId, sharedAt: crmContactShares.sharedAt, isActive: crmContactShares.isActive, - remark: crmContactShares.remark + remark: crmContactShares.remark, }) .from(crmContactShares) .leftJoin(users, eq(users.id, crmContactShares.sharedToUserId)) @@ -389,8 +438,8 @@ async function listContactSharesMap(contactIds: string[], organizationId: string eq(crmContactShares.organizationId, organizationId), inArray(crmContactShares.contactId, contactIds), eq(crmContactShares.isActive, true), - isNull(crmContactShares.deletedAt) - ) + isNull(crmContactShares.deletedAt), + ), ) .orderBy(desc(crmContactShares.sharedAt)); @@ -402,7 +451,7 @@ async function listContactSharesMap(contactIds: string[], organizationId: string .where(inArray(users.id, sharedByIds)) : []; const sharedByMap = new Map(sharedByRows.map((row) => [row.id, row.name])); - const shareMap = new Map(); + const shareMap = new Map(); for (const row of shareRows) { const items = shareMap.get(row.contactId) ?? []; @@ -415,7 +464,7 @@ async function listContactSharesMap(contactIds: string[], organizationId: string sharedByName: sharedByMap.get(row.sharedByUserId) ?? null, sharedAt: row.sharedAt.toISOString(), isActive: row.isActive, - remark: row.remark ?? null + remark: row.remark ?? null, }); shareMap.set(row.contactId, items); } @@ -425,7 +474,7 @@ async function listContactSharesMap(contactIds: string[], organizationId: string async function resolveAccessibleCustomerRelationSets( organizationId: string, - accessContext: CustomerAccessContext + accessContext: CustomerAccessContext, ) { const [enquiries, quotations] = await Promise.all([ db @@ -434,11 +483,14 @@ async function resolveAccessibleCustomerRelationSets( createdBy: crmEnquiries.createdBy, assignedToUserId: crmEnquiries.assignedToUserId, branchId: crmEnquiries.branchId, - productType: crmEnquiries.productType + productType: crmEnquiries.productType, }) .from(crmEnquiries) .where( - and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt)) + and( + eq(crmEnquiries.organizationId, organizationId), + isNull(crmEnquiries.deletedAt), + ), ), db .select({ @@ -446,12 +498,15 @@ async function resolveAccessibleCustomerRelationSets( createdBy: crmQuotations.createdBy, salesmanId: crmQuotations.salesmanId, branchId: crmQuotations.branchId, - quotationType: crmQuotations.quotationType + quotationType: crmQuotations.quotationType, }) .from(crmQuotations) .where( - and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)) - ) + and( + eq(crmQuotations.organizationId, organizationId), + isNull(crmQuotations.deletedAt), + ), + ), ]); const enquiryCustomerIds = new Set(); @@ -467,10 +522,10 @@ async function resolveAccessibleCustomerRelationSets( } if ( - accessContext.membershipRole === 'admin' || - accessContext.ownershipScope === 'organization' || - accessContext.ownershipScope === 'team' || - accessContext.ownershipScope === 'monitor' || + accessContext.membershipRole === "admin" || + accessContext.ownershipScope === "organization" || + accessContext.ownershipScope === "team" || + accessContext.ownershipScope === "monitor" || row.createdBy === accessContext.userId || row.assignedToUserId === accessContext.userId ) { @@ -488,9 +543,9 @@ async function resolveAccessibleCustomerRelationSets( } if ( - accessContext.membershipRole === 'admin' || - accessContext.ownershipScope === 'organization' || - accessContext.ownershipScope === 'team' || + accessContext.membershipRole === "admin" || + accessContext.ownershipScope === "organization" || + accessContext.ownershipScope === "team" || row.createdBy === accessContext.userId || row.salesmanId === accessContext.userId ) { @@ -504,13 +559,15 @@ async function resolveAccessibleCustomerRelationSets( async function canAccessCustomerRow( row: CustomerRecordSource, accessContext: CustomerAccessContext, - relationSets?: Awaited> + relationSets?: Awaited< + ReturnType + >, ) { if (!canAccessCustomer(accessContext, row)) { if ( - accessContext.membershipRole === 'admin' || - accessContext.ownershipScope === 'organization' || - accessContext.ownershipScope === 'team' + accessContext.membershipRole === "admin" || + accessContext.ownershipScope === "organization" || + accessContext.ownershipScope === "team" ) { return false; } @@ -521,18 +578,21 @@ async function canAccessCustomerRow( } if ( - accessContext.membershipRole === 'admin' || - accessContext.ownershipScope === 'organization' || - accessContext.ownershipScope === 'team' + accessContext.membershipRole === "admin" || + accessContext.ownershipScope === "organization" || + accessContext.ownershipScope === "team" ) { return true; } const { enquiryCustomerIds, quotationCustomerIds } = relationSets ?? - (await resolveAccessibleCustomerRelationSets(row.organizationId, accessContext)); + (await resolveAccessibleCustomerRelationSets( + row.organizationId, + accessContext, + )); - if (accessContext.ownershipScope === 'monitor') { + if (accessContext.ownershipScope === "monitor") { return enquiryCustomerIds.has(row.id); } @@ -547,12 +607,15 @@ async function canAccessCustomerRow( async function assertCustomerAccess( id: string, organizationId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { - const customer = await assertCustomerBelongsToOrganization(id, organizationId); + const customer = await assertCustomerBelongsToOrganization( + id, + organizationId, + ); if (accessContext && !(await canAccessCustomerRow(customer, accessContext))) { - throw new AuthError('Forbidden', 403); + throw new AuthError("Forbidden", 403); } return customer; @@ -562,13 +625,13 @@ async function canAccessContactRow( contact: typeof crmCustomerContacts.$inferSelect, customer: CustomerRecordSource, accessContext: CustomerAccessContext, - shares?: CustomerContactRecord['shares'] + shares?: CustomerContactRecord["shares"], ) { return canAccessContact(accessContext, { branchId: customer.branchId, createdBy: contact.createdBy, ownerUserId: customer.ownerUserId, - sharedToUserIds: (shares ?? []).map((share) => share.sharedToUserId) + sharedToUserIds: (shares ?? []).map((share) => share.sharedToUserId), }); } @@ -576,17 +639,32 @@ export async function assertContactAccess( customerId: string, contactId: string, organizationId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { - const customer = await assertCustomerBelongsToOrganization(customerId, organizationId); - const contact = await assertContactBelongsToCustomer(contactId, customerId, organizationId); + const customer = await assertCustomerBelongsToOrganization( + customerId, + organizationId, + ); + const contact = await assertContactBelongsToCustomer( + contactId, + customerId, + organizationId, + ); if (accessContext) { - const shares = (await listContactSharesMap([contact.id], organizationId)).get(contact.id) ?? []; - const visible = await canAccessContactRow(contact, customer, accessContext, shares); + const shares = + (await listContactSharesMap([contact.id], organizationId)).get( + contact.id, + ) ?? []; + const visible = await canAccessContactRow( + contact, + customer, + accessContext, + shares, + ); if (!visible) { - throw new AuthError('Forbidden', 403); + throw new AuthError("Forbidden", 403); } } @@ -596,49 +674,52 @@ export async function assertContactAccess( async function assertContactBelongsToCustomer( contactId: string, customerId: string, - organizationId: string + organizationId: string, ) { const contact = await db.query.crmCustomerContacts.findFirst({ where: and( eq(crmCustomerContacts.id, contactId), eq(crmCustomerContacts.customerId, customerId), eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt) - ) + isNull(crmCustomerContacts.deletedAt), + ), }); if (!contact) { - throw new AuthError('Contact not found', 404); + throw new AuthError("Contact not found", 404); } return contact; } -async function validateCustomerPayload(organizationId: string, payload: CustomerMutationPayload) { +async function validateCustomerPayload( + organizationId: string, + payload: CustomerMutationPayload, +) { await assertMasterOptionValue( organizationId, CUSTOMER_OPTION_CATEGORIES.customerType, - payload.customerType + payload.customerType, ); await assertMasterOptionValue( organizationId, CUSTOMER_OPTION_CATEGORIES.customerStatus, - payload.customerStatus + payload.customerStatus, ); await assertMasterOptionValue( organizationId, CUSTOMER_OPTION_CATEGORIES.leadChannel, - payload.leadChannel ?? null + payload.leadChannel ?? null, ); await assertMasterOptionValue( organizationId, CUSTOMER_OPTION_CATEGORIES.customerGroup, - payload.customerGroup ?? null + payload.customerGroup ?? null, ); await assertMasterOptionValue( organizationId, CUSTOMER_OPTION_CATEGORIES.customerSubGroup, - payload.customerSubGroup ?? null + payload.customerSubGroup ?? null, ); if (payload.branchId) { @@ -647,7 +728,7 @@ async function validateCustomerPayload(organizationId: string, payload: Customer if (payload.customerSubGroup) { if (!payload.customerGroup) { - throw new AuthError('Customer sub group requires a customer group', 400); + throw new AuthError("Customer sub group requires a customer group", 400); } const subGroup = await db.query.msOptions.findFirst({ @@ -655,21 +736,27 @@ async function validateCustomerPayload(organizationId: string, payload: Customer eq(msOptions.organizationId, organizationId), eq(msOptions.category, CUSTOMER_OPTION_CATEGORIES.customerSubGroup), eq(msOptions.id, payload.customerSubGroup), - isNull(msOptions.deletedAt) - ) + isNull(msOptions.deletedAt), + ), }); if (!subGroup) { - throw new AuthError('Invalid customer sub group', 400); + throw new AuthError("Invalid customer sub group", 400); } if (subGroup.parentId !== payload.customerGroup) { - throw new AuthError('Selected customer sub group does not belong to the selected customer group', 400); + throw new AuthError( + "Selected customer sub group does not belong to the selected customer group", + 400, + ); } } } -function buildCustomerFilters(organizationId: string, filters: CustomerFilters): SQL[] { +function buildCustomerFilters( + organizationId: string, + filters: CustomerFilters, +): SQL[] { const statuses = splitFilterValue(filters.customerStatus); const customerTypes = splitFilterValue(filters.customerType); const branches = splitFilterValue(filters.branch); @@ -678,8 +765,12 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters): return [ eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt), - ...(statuses.length ? [inArray(crmCustomers.customerStatus, statuses)] : []), - ...(customerTypes.length ? [inArray(crmCustomers.customerType, customerTypes)] : []), + ...(statuses.length + ? [inArray(crmCustomers.customerStatus, statuses)] + : []), + ...(customerTypes.length + ? [inArray(crmCustomers.customerType, customerTypes)] + : []), ...(branches.length ? [inArray(crmCustomers.branchId, branches)] : []), ...(owners.length ? [inArray(crmCustomers.ownerUserId, owners)] : []), ...(filters.search @@ -689,15 +780,15 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters): ilike(crmCustomers.name, `%${filters.search}%`), ilike(crmCustomers.abbr, `%${filters.search}%`), ilike(crmCustomers.taxId, `%${filters.search}%`), - ilike(crmCustomers.email, `%${filters.search}%`) - )! + ilike(crmCustomers.email, `%${filters.search}%`), + )!, ] - : []) + : []), ]; } export async function getCustomerReferenceData( - organizationId: string + organizationId: string, ): Promise { const [ branches, @@ -706,29 +797,35 @@ export async function getCustomerReferenceData( leadChannels, customerGroups, customerSubGroups, - crmUsers - ] = - await Promise.all([ - getUserBranches(), - getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }), - getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerStatus, { organizationId }), - getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.leadChannel, { organizationId }), - getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerGroup, { organizationId }), - db - .select() - .from(msOptions) - .where( - and( - eq(msOptions.organizationId, organizationId), - eq(msOptions.category, CUSTOMER_OPTION_CATEGORIES.customerSubGroup), - eq(msOptions.isActive, true), - isNull(msOptions.deletedAt) - ) - ) - .orderBy(asc(msOptions.sortOrder), asc(msOptions.label)) - , - listCrmUsersForOrganization(organizationId) - ]); + crmUsers, + ] = await Promise.all([ + getUserBranches(), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { + organizationId, + }), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerStatus, { + organizationId, + }), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.leadChannel, { + organizationId, + }), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerGroup, { + organizationId, + }), + db + .select() + .from(msOptions) + .where( + and( + eq(msOptions.organizationId, organizationId), + eq(msOptions.category, CUSTOMER_OPTION_CATEGORIES.customerSubGroup), + eq(msOptions.isActive, true), + isNull(msOptions.deletedAt), + ), + ) + .orderBy(asc(msOptions.sortOrder), asc(msOptions.label)), + listCrmUsersForOrganization(organizationId), + ]); return { branches: branches.map(mapBranchOption), @@ -737,64 +834,64 @@ export async function getCustomerReferenceData( leadChannels: leadChannels.map(mapCustomerOption), customerGroups: customerGroups.map(mapCustomerOption), customerSubGroups: customerSubGroups.map(mapCustomerOptionRow), - crmUsers + crmUsers, }; } export async function listCustomers( organizationId: string, filters: CustomerFilters, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ): Promise<{ items: CustomerListItem[]; totalItems: number }> { - const includeCustomerSubGroup = await hasCustomerSubGroupColumn(); const page = filters.page ?? 1; const limit = filters.limit ?? 10; const whereFilters = buildCustomerFilters(organizationId, filters); - if (filters.myCustomers === 'true' && accessContext) { + if (filters.myCustomers === "true" && accessContext) { whereFilters.push(eq(crmCustomers.ownerUserId, accessContext.userId)); } - const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); + const where = + whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); const offset = (page - 1) * limit; const rows = await db.execute(sql` select - c.id, - c.organization_id as "organizationId", - c.branch_id as "branchId", - c.code, - c.name, - c.abbr, - c.tax_id as "taxId", - c.customer_type as "customerType", - c.customer_status as "customerStatus", - c.address, - c.province, - c.district, - c.sub_district as "subDistrict", - c.postal_code as "postalCode", - c.country, - c.phone, - c.fax, - c.email, - c.website, - c.lead_channel as "leadChannel", - c.customer_group as "customerGroup", - ${includeCustomerSubGroup ? sql`c.customer_sub_group` : sql`null`} as "customerSubGroup", - c.owner_user_id as "ownerUserId", - c.owner_assigned_at as "ownerAssignedAt", - c.owner_assigned_by as "ownerAssignedBy", - c.notes, - c.is_active as "isActive", - c.created_at as "createdAt", - c.updated_at as "updatedAt", - c.deleted_at as "deletedAt", - c.created_by as "createdBy", - c.updated_by as "updatedBy", + crm_customers.id, + crm_customers.organization_id as "organizationId", + crm_customers.branch_id as "branchId", + crm_customers.code, + crm_customers.name, + crm_customers.abbr, + crm_customers.tax_id as "taxId", + crm_customers.customer_type as "customerType", + crm_customers.customer_status as "customerStatus", + crm_customers.address, + crm_customers.province, + crm_customers.district, + crm_customers.sub_district as "subDistrict", + crm_customers.postal_code as "postalCode", + crm_customers.country, + crm_customers.phone, + crm_customers.fax, + crm_customers.email, + crm_customers.website, + crm_customers.lead_channel as "leadChannel", + crm_customers.customer_group as "customerGroup", + crm_customers.customer_sub_group as "customerSubGroup", + crm_customers.owner_user_id as "ownerUserId", + crm_customers.owner_assigned_at as "ownerAssignedAt", + crm_customers.owner_assigned_by as "ownerAssignedBy", + crm_customers.notes, + crm_customers.is_active as "isActive", + crm_customers.created_at as "createdAt", + crm_customers.updated_at as "updatedAt", + crm_customers.deleted_at as "deletedAt", + crm_customers.created_by as "createdBy", + crm_customers.updated_by as "updatedBy", owner_u.name as "ownerName", assigned_by_u.name as "ownerAssignedByName" - from crm_customers c - left join users owner_u on owner_u.id = c.owner_user_id - left join users assigned_by_u on assigned_by_u.id = c.owner_assigned_by + from crm_customers + left join users owner_u on owner_u.id = crm_customers.owner_user_id + left join users assigned_by_u on assigned_by_u.id = crm_customers.owner_assigned_by where ${where} order by ${parseSort(filters.sort)} `); @@ -806,8 +903,14 @@ export async function listCustomers( ? ( await Promise.all( rows.map(async (row) => - (await canAccessCustomerRow(row, accessContext, relationSets ?? undefined)) ? row : null - ) + (await canAccessCustomerRow( + row, + accessContext, + relationSets ?? undefined, + )) + ? row + : null, + ), ) ).filter((row): row is CustomerRecordSource => row !== null) : rows; @@ -818,36 +921,45 @@ export async function listCustomers( ? await db .select({ customerId: crmCustomerContacts.customerId, - value: count() + value: count(), }) .from(crmCustomerContacts) .where( and( eq(crmCustomerContacts.organizationId, organizationId), inArray(crmCustomerContacts.customerId, customerIds), - isNull(crmCustomerContacts.deletedAt) - ) + isNull(crmCustomerContacts.deletedAt), + ), ) .groupBy(crmCustomerContacts.customerId) : []; - const countMap = new Map(contactCounts.map((item) => [item.customerId, item.value])); + const countMap = new Map( + contactCounts.map((item) => [item.customerId, item.value]), + ); return { items: pagedRows.map((row) => ({ ...mapCustomerRecord(row), - contactCount: countMap.get(row.id) ?? 0 + contactCount: countMap.get(row.id) ?? 0, })), - totalItems: scopedRows.length + totalItems: scopedRows.length, }; } export async function getCustomerDetail( id: string, organizationId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ): Promise { - const customer = await assertCustomerAccess(id, organizationId, accessContext); - const relatedUserIds = [customer.ownerUserId, customer.ownerAssignedBy].filter(Boolean) as string[]; + const customer = await assertCustomerAccess( + id, + organizationId, + accessContext, + ); + const relatedUserIds = [ + customer.ownerUserId, + customer.ownerAssignedBy, + ].filter(Boolean) as string[]; const userRows = relatedUserIds.length ? await db .select({ id: users.id, name: users.name }) @@ -858,22 +970,24 @@ export async function getCustomerDetail( return mapCustomerRecord({ ...customer, - ownerName: customer.ownerUserId ? (userMap.get(customer.ownerUserId) ?? null) : null, + ownerName: customer.ownerUserId + ? (userMap.get(customer.ownerUserId) ?? null) + : null, ownerAssignedByName: customer.ownerAssignedBy ? (userMap.get(customer.ownerAssignedBy) ?? null) - : null + : null, }); } export async function getCustomerActivity( id: string, - organizationId: string + organizationId: string, ): Promise { const auditLogs = await listAuditLogs({ organizationId, - entityType: 'crm_customer', + entityType: "crm_customer", entityId: id, - limit: 25 + limit: 25, }); const userIds = [...new Set(auditLogs.map((log) => log.userId))]; const actorRows = userIds.length @@ -891,25 +1005,35 @@ export async function getCustomerActivity( entityId: log.entityId, createdAt: log.createdAt, userId: log.userId, - actorName: actorMap.get(log.userId) ?? null + actorName: actorMap.get(log.userId) ?? null, })); } export async function getCustomerOwnerHistory( id: string, organizationId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ): Promise { await assertCustomerAccess(id, organizationId, accessContext); const rows = await db.query.crmCustomerOwnerHistory.findMany({ where: and( eq(crmCustomerOwnerHistory.customerId, id), eq(crmCustomerOwnerHistory.organizationId, organizationId), - isNull(crmCustomerOwnerHistory.deletedAt) + isNull(crmCustomerOwnerHistory.deletedAt), ), - orderBy: [desc(crmCustomerOwnerHistory.changedAt)] + orderBy: [desc(crmCustomerOwnerHistory.changedAt)], }); - const userIds = [...new Set(rows.flatMap((row) => [row.oldOwnerUserId, row.newOwnerUserId, row.changedBy]).filter(Boolean) as string[])]; + const userIds = [ + ...new Set( + rows + .flatMap((row) => [ + row.oldOwnerUserId, + row.newOwnerUserId, + row.changedBy, + ]) + .filter(Boolean) as string[], + ), + ]; const userRows = userIds.length ? await db .select({ id: users.id, name: users.name }) @@ -921,24 +1045,28 @@ export async function getCustomerOwnerHistory( return rows.map((row) => mapOwnerHistoryRecord({ ...row, - oldOwnerName: row.oldOwnerUserId ? (userMap.get(row.oldOwnerUserId) ?? null) : null, - newOwnerName: row.newOwnerUserId ? (userMap.get(row.newOwnerUserId) ?? null) : null, - changedByName: userMap.get(row.changedBy) ?? null - }) + oldOwnerName: row.oldOwnerUserId + ? (userMap.get(row.oldOwnerUserId) ?? null) + : null, + newOwnerName: row.newOwnerUserId + ? (userMap.get(row.newOwnerUserId) ?? null) + : null, + changedByName: userMap.get(row.changedBy) ?? null, + }), ); } export async function createCustomer( organizationId: string, userId: string, - payload: CustomerMutationPayload + payload: CustomerMutationPayload, ) { await validateCustomerPayload(organizationId, payload); const documentCode = await generateNextDocumentCode({ organizationId, - documentType: 'customer', - branchId: payload.branchId ?? '' + documentType: "customer", + branchId: payload.branchId ?? "", }); const [created] = await db @@ -969,7 +1097,7 @@ export async function createCustomer( notes: payload.notes?.trim() || null, isActive: payload.isActive ?? true, createdBy: userId, - updatedBy: userId + updatedBy: userId, }) .returning(); @@ -992,7 +1120,7 @@ async function recordCustomerOwnerHistory(input: { newOwnerUserId: input.newOwnerUserId ?? null, changedBy: input.changedBy, changedAt: new Date(), - remark: input.remark?.trim() || null + remark: input.remark?.trim() || null, }); } @@ -1001,14 +1129,21 @@ export async function assignCustomerOwner( organizationId: string, userId: string, payload: CustomerOwnerMutationPayload, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { if (!payload.ownerUserId) { - throw new AuthError('Owner user is required', 400); + throw new AuthError("Owner user is required", 400); } - const current = await assertCustomerAccess(customerId, organizationId, accessContext); - await assertOwnerUserBelongsToOrganization(payload.ownerUserId, organizationId); + const current = await assertCustomerAccess( + customerId, + organizationId, + accessContext, + ); + await assertOwnerUserBelongsToOrganization( + payload.ownerUserId, + organizationId, + ); if (current.ownerUserId === payload.ownerUserId) { return current; @@ -1021,7 +1156,7 @@ export async function assignCustomerOwner( ownerAssignedAt: new Date(), ownerAssignedBy: userId, updatedAt: new Date(), - updatedBy: userId + updatedBy: userId, }) .where(eq(crmCustomers.id, customerId)) .returning(); @@ -1032,7 +1167,7 @@ export async function assignCustomerOwner( oldOwnerUserId: current.ownerUserId, newOwnerUserId: payload.ownerUserId, changedBy: userId, - remark: payload.remark + remark: payload.remark, }); return updated; @@ -1042,10 +1177,14 @@ export async function clearCustomerOwner( customerId: string, organizationId: string, userId: string, - payload?: Pick, - accessContext?: CustomerAccessContext + payload?: Pick, + accessContext?: CustomerAccessContext, ) { - const current = await assertCustomerAccess(customerId, organizationId, accessContext); + const current = await assertCustomerAccess( + customerId, + organizationId, + accessContext, + ); if (!current.ownerUserId) { return current; @@ -1058,7 +1197,7 @@ export async function clearCustomerOwner( ownerAssignedAt: null, ownerAssignedBy: null, updatedAt: new Date(), - updatedBy: userId + updatedBy: userId, }) .where(eq(crmCustomers.id, customerId)) .returning(); @@ -1069,7 +1208,7 @@ export async function clearCustomerOwner( oldOwnerUserId: current.ownerUserId, newOwnerUserId: null, changedBy: userId, - remark: payload?.remark + remark: payload?.remark, }); return updated; @@ -1080,7 +1219,7 @@ export async function updateCustomer( organizationId: string, userId: string, payload: CustomerMutationPayload, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { await validateCustomerPayload(organizationId, payload); await assertCustomerAccess(id, organizationId, accessContext); @@ -1110,7 +1249,7 @@ export async function updateCustomer( notes: payload.notes?.trim() || null, isActive: payload.isActive ?? true, updatedBy: userId, - updatedAt: new Date() + updatedAt: new Date(), }) .where(eq(crmCustomers.id, id)) .returning(); @@ -1122,7 +1261,7 @@ export async function softDeleteCustomer( id: string, organizationId: string, userId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { await assertCustomerAccess(id, organizationId, accessContext); @@ -1134,7 +1273,7 @@ export async function softDeleteCustomer( deletedAt: now, updatedAt: now, updatedBy: userId, - isActive: false + isActive: false, }) .where(eq(crmCustomers.id, id)) .returning(); @@ -1145,13 +1284,13 @@ export async function softDeleteCustomer( deletedAt: now, updatedAt: now, updatedBy: userId, - isActive: false + isActive: false, }) .where( and( eq(crmCustomerContacts.customerId, id), - eq(crmCustomerContacts.organizationId, organizationId) - ) + eq(crmCustomerContacts.organizationId, organizationId), + ), ); return updated; @@ -1160,42 +1299,57 @@ export async function softDeleteCustomer( export async function listCustomerContacts( id: string, organizationId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { - const customer = await assertCustomerBelongsToOrganization(id, organizationId); + const customer = await assertCustomerBelongsToOrganization( + id, + organizationId, + ); const rows = await db.query.crmCustomerContacts.findMany({ where: and( eq(crmCustomerContacts.customerId, id), eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt) + isNull(crmCustomerContacts.deletedAt), ), - orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)] + orderBy: [ + desc(crmCustomerContacts.isPrimary), + asc(crmCustomerContacts.name), + ], }); const shareMap = await listContactSharesMap( rows.map((row) => row.id), - organizationId + organizationId, ); const scopedRows = accessContext ? ( await Promise.all( rows.map(async (row) => - (await canAccessContactRow(row, customer, accessContext, shareMap.get(row.id) ?? [])) + (await canAccessContactRow( + row, + customer, + accessContext, + shareMap.get(row.id) ?? [], + )) ? row - : null - ) + : null, + ), ) - ).filter((row): row is typeof crmCustomerContacts.$inferSelect => row !== null) + ).filter( + (row): row is typeof crmCustomerContacts.$inferSelect => row !== null, + ) : rows; if (accessContext && !scopedRows.length) { const canViewCustomer = await canAccessCustomerRow(customer, accessContext); if (!canViewCustomer) { - throw new AuthError('Forbidden', 403); + throw new AuthError("Forbidden", 403); } } - return scopedRows.map((row) => mapCustomerContactRecord(row, shareMap.get(row.id) ?? [])); + return scopedRows.map((row) => + mapCustomerContactRecord(row, shareMap.get(row.id) ?? []), + ); } export async function createCustomerContact( @@ -1203,7 +1357,7 @@ export async function createCustomerContact( organizationId: string, userId: string, payload: CustomerContactMutationPayload, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { await assertCustomerAccess(customerId, organizationId, accessContext); @@ -1214,14 +1368,14 @@ export async function createCustomerContact( .set({ isPrimary: false, updatedAt: new Date(), - updatedBy: userId + updatedBy: userId, }) .where( and( eq(crmCustomerContacts.customerId, customerId), eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt) - ) + isNull(crmCustomerContacts.deletedAt), + ), ); } @@ -1241,7 +1395,7 @@ export async function createCustomerContact( notes: payload.notes?.trim() || null, isActive: payload.isActive ?? true, createdBy: userId, - updatedBy: userId + updatedBy: userId, }) .returning(); @@ -1255,9 +1409,14 @@ export async function updateCustomerContact( organizationId: string, userId: string, payload: CustomerContactMutationPayload, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { - await assertContactAccess(customerId, contactId, organizationId, accessContext); + await assertContactAccess( + customerId, + contactId, + organizationId, + accessContext, + ); return db.transaction(async (tx) => { if (payload.isPrimary) { @@ -1266,14 +1425,14 @@ export async function updateCustomerContact( .set({ isPrimary: false, updatedAt: new Date(), - updatedBy: userId + updatedBy: userId, }) .where( and( eq(crmCustomerContacts.customerId, customerId), eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt) - ) + isNull(crmCustomerContacts.deletedAt), + ), ); } @@ -1290,7 +1449,7 @@ export async function updateCustomerContact( notes: payload.notes?.trim() || null, isActive: payload.isActive ?? true, updatedAt: new Date(), - updatedBy: userId + updatedBy: userId, }) .where(eq(crmCustomerContacts.id, contactId)) .returning(); @@ -1304,9 +1463,14 @@ export async function softDeleteCustomerContact( contactId: string, organizationId: string, userId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { - await assertContactAccess(customerId, contactId, organizationId, accessContext); + await assertContactAccess( + customerId, + contactId, + organizationId, + accessContext, + ); const [updated] = await db .update(crmCustomerContacts) @@ -1315,7 +1479,7 @@ export async function softDeleteCustomerContact( updatedAt: new Date(), updatedBy: userId, isActive: false, - isPrimary: false + isPrimary: false, }) .where(eq(crmCustomerContacts.id, contactId)) .returning(); @@ -1329,18 +1493,21 @@ export async function shareCustomerContact( organizationId: string, userId: string, payload: { sharedToUserId: string; remark?: string | null }, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { await assertCustomerAccess(customerId, organizationId, accessContext); await assertContactBelongsToCustomer(contactId, customerId, organizationId); - await assertOwnerUserBelongsToOrganization(payload.sharedToUserId, organizationId); + await assertOwnerUserBelongsToOrganization( + payload.sharedToUserId, + organizationId, + ); const existing = await db.query.crmContactShares.findFirst({ where: and( eq(crmContactShares.organizationId, organizationId), eq(crmContactShares.contactId, contactId), - eq(crmContactShares.sharedToUserId, payload.sharedToUserId) - ) + eq(crmContactShares.sharedToUserId, payload.sharedToUserId), + ), }); if (existing) { @@ -1352,7 +1519,7 @@ export async function shareCustomerContact( isActive: true, remark: payload.remark?.trim() || null, deletedAt: null, - updatedAt: new Date() + updatedAt: new Date(), }) .where(eq(crmContactShares.id, existing.id)) .returning(); @@ -1370,7 +1537,7 @@ export async function shareCustomerContact( sharedByUserId: userId, sharedAt: new Date(), isActive: true, - remark: payload.remark?.trim() || null + remark: payload.remark?.trim() || null, }) .returning(); @@ -1381,10 +1548,18 @@ export async function listCustomerContactShares( customerId: string, contactId: string, organizationId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { - await assertContactAccess(customerId, contactId, organizationId, accessContext); - return (await listContactSharesMap([contactId], organizationId)).get(contactId) ?? []; + await assertContactAccess( + customerId, + contactId, + organizationId, + accessContext, + ); + return ( + (await listContactSharesMap([contactId], organizationId)).get(contactId) ?? + [] + ); } export async function unshareCustomerContact( @@ -1392,7 +1567,7 @@ export async function unshareCustomerContact( contactId: string, shareId: string, organizationId: string, - accessContext?: CustomerAccessContext + accessContext?: CustomerAccessContext, ) { await assertCustomerAccess(customerId, organizationId, accessContext); await assertContactBelongsToCustomer(contactId, customerId, organizationId); @@ -1402,19 +1577,19 @@ export async function unshareCustomerContact( .set({ isActive: false, deletedAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }) .where( and( eq(crmContactShares.id, shareId), eq(crmContactShares.organizationId, organizationId), - eq(crmContactShares.contactId, contactId) - ) + eq(crmContactShares.contactId, contactId), + ), ) .returning(); if (!updated) { - throw new AuthError('Share not found', 404); + throw new AuthError("Share not found", 404); } return updated; @@ -1423,7 +1598,7 @@ export async function unshareCustomerContact( export async function resolveCustomerOptionLabel( organizationId: string, category: string, - id: string | null + id: string | null, ) { if (!id) { return null; @@ -1434,8 +1609,8 @@ export async function resolveCustomerOptionLabel( eq(msOptions.organizationId, organizationId), eq(msOptions.category, category), eq(msOptions.id, id), - isNull(msOptions.deletedAt) - ) + isNull(msOptions.deletedAt), + ), }); return row?.label ?? null; diff --git a/src/features/crm/reports/api/queries.ts b/src/features/crm/reports/api/queries.ts new file mode 100644 index 0000000..5b5a69f --- /dev/null +++ b/src/features/crm/reports/api/queries.ts @@ -0,0 +1,27 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getCrmReportCatalog, getCrmReportFilters, getCrmReports } from './service'; + +export const crmReportKeys = { + all: ['crm-reports'] as const, + list: () => [...crmReportKeys.all, 'list'] as const, + catalog: () => [...crmReportKeys.all, 'catalog'] as const, + filters: () => [...crmReportKeys.all, 'filters'] as const +}; + +export const crmReportsQueryOptions = () => + queryOptions({ + queryKey: crmReportKeys.list(), + queryFn: () => getCrmReports() + }); + +export const crmReportCatalogQueryOptions = () => + queryOptions({ + queryKey: crmReportKeys.catalog(), + queryFn: () => getCrmReportCatalog() + }); + +export const crmReportFiltersQueryOptions = () => + queryOptions({ + queryKey: crmReportKeys.filters(), + queryFn: () => getCrmReportFilters() + }); diff --git a/src/features/crm/reports/api/service.ts b/src/features/crm/reports/api/service.ts new file mode 100644 index 0000000..2f866aa --- /dev/null +++ b/src/features/crm/reports/api/service.ts @@ -0,0 +1,18 @@ +import { apiClient } from '@/lib/api-client'; +import type { + CrmReportCatalogResponse, + CrmReportFiltersResponse, + CrmReportsResponse +} from './types'; + +export async function getCrmReports() { + return apiClient('/crm/reports'); +} + +export async function getCrmReportCatalog() { + return apiClient('/crm/reports/catalog'); +} + +export async function getCrmReportFilters() { + return apiClient('/crm/reports/filters'); +} diff --git a/src/features/crm/reports/api/types.ts b/src/features/crm/reports/api/types.ts new file mode 100644 index 0000000..c79373a --- /dev/null +++ b/src/features/crm/reports/api/types.ts @@ -0,0 +1,75 @@ +export interface CrmReportDefinitionRecord { + id: string; + code: string; + name: string; + description: string | null; + category: string; + categoryLabel: string; + isSystem: boolean; + isActive: boolean; +} + +export interface CrmReportCatalogGroup { + category: string; + categoryLabel: string; + items: CrmReportDefinitionRecord[]; +} + +export interface CrmReportFilterOption { + id: string; + code: string; + label: string; +} + +export interface CrmReportUserOption { + id: string; + name: string; +} + +export interface CrmSharedReportFilters { + dateFrom?: string | null; + dateTo?: string | null; + branch?: string | null; + productType?: string | null; + sales?: string | null; + customer?: string | null; + customerOwner?: string | null; + leadSource?: string | null; + pipelineStage?: string | null; + outcome?: string | null; + lostReason?: string | null; +} + +export interface CrmReportFilterMetadata { + branches: CrmReportFilterOption[]; + productTypes: CrmReportFilterOption[]; + sales: CrmReportUserOption[]; + customers: CrmReportFilterOption[]; + customerOwners: CrmReportUserOption[]; + leadSources: CrmReportFilterOption[]; + pipelineStages: CrmReportFilterOption[]; + outcomes: CrmReportFilterOption[]; + lostReasons: CrmReportFilterOption[]; +} + +export interface CrmReportsResponse { + success: boolean; + time: string; + message: string; + items: CrmReportDefinitionRecord[]; + groups: CrmReportCatalogGroup[]; +} + +export interface CrmReportCatalogResponse { + success: boolean; + time: string; + message: string; + groups: CrmReportCatalogGroup[]; +} + +export interface CrmReportFiltersResponse { + success: boolean; + time: string; + message: string; + filters: CrmReportFilterMetadata; +} diff --git a/src/features/crm/reports/components/report-catalog.tsx b/src/features/crm/reports/components/report-catalog.tsx new file mode 100644 index 0000000..f38e377 --- /dev/null +++ b/src/features/crm/reports/components/report-catalog.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../api/queries'; + +export function ReportCatalog() { + const { data: catalog } = useSuspenseQuery(crmReportCatalogQueryOptions()); + const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions()); + + return ( +
+ + + Shared Report Filters + + Central filter metadata prepared for upcoming CRM report modules. + + + +
+
Date Range
+
Shared dateFrom/dateTo contract for all reports.
+
+ {[ + ['Branches', filterData.filters.branches.length], + ['Product Types', filterData.filters.productTypes.length], + ['Sales', filterData.filters.sales.length], + ['Customers', filterData.filters.customers.length], + ['Customer Owners', filterData.filters.customerOwners.length], + ['Lead Sources', filterData.filters.leadSources.length], + ['Pipeline Stages', filterData.filters.pipelineStages.length], + ['Outcomes', filterData.filters.outcomes.length], + ['Lost Reasons', filterData.filters.lostReasons.length] + ].map(([label, count]) => ( +
+
{label}
+
{count} options available
+
+ ))} +
+
+ +
+ {catalog.groups.map((group) => ( + + + {group.categoryLabel} + + {group.items.length} report definition{group.items.length === 1 ? '' : 's'} ready for future modules. + + + + {group.items.map((item) => ( +
+
+
+
{item.name}
+
{item.description}
+
+ {item.isSystem ? System : null} +
+
{item.code}
+
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/features/crm/reports/server/builders/catalog-builder.ts b/src/features/crm/reports/server/builders/catalog-builder.ts new file mode 100644 index 0000000..e4c5496 --- /dev/null +++ b/src/features/crm/reports/server/builders/catalog-builder.ts @@ -0,0 +1,21 @@ +import type { CrmReportCatalogGroup, CrmReportDefinitionRecord } from '../../api/types'; + +export function buildReportCatalogGroups( + items: CrmReportDefinitionRecord[] +): CrmReportCatalogGroup[] { + const grouped = new Map(); + + for (const item of items) { + const current = + grouped.get(item.category) ?? + { + category: item.category, + categoryLabel: item.categoryLabel, + items: [] + }; + current.items.push(item); + grouped.set(item.category, current); + } + + return [...grouped.values()]; +} diff --git a/src/features/crm/reports/server/context.ts b/src/features/crm/reports/server/context.ts new file mode 100644 index 0000000..3ad0d6a --- /dev/null +++ b/src/features/crm/reports/server/context.ts @@ -0,0 +1,33 @@ +import type { ResolvedReportContext } from './types'; + +export function resolveCrmAccess(input: { + organizationId: string; + userId: string; + membership: { + role: string; + businessRole: string; + businessRoles?: string[]; + permissions?: string[] | null; + branchScopeIds?: string[] | null; + productTypeScopeIds?: string[] | null; + ownershipScope?: string | null; + branchScopeMode?: string | null; + productScopeMode?: string | null; + approvalAuthority?: string | null; + }; +}): ResolvedReportContext { + return { + organizationId: input.organizationId, + userId: input.userId, + membershipRole: input.membership.role, + businessRole: input.membership.businessRole, + businessRoles: input.membership.businessRoles ?? [input.membership.businessRole], + permissions: input.membership.permissions ?? [], + branchScopeIds: input.membership.branchScopeIds ?? [], + productTypeScopeIds: input.membership.productTypeScopeIds ?? [], + ownershipScope: input.membership.ownershipScope ?? 'organization', + branchScopeMode: input.membership.branchScopeMode ?? 'all', + productScopeMode: input.membership.productScopeMode ?? 'all', + approvalAuthority: input.membership.approvalAuthority ?? null + }; +} diff --git a/src/features/crm/reports/server/datasets/catalog.ts b/src/features/crm/reports/server/datasets/catalog.ts new file mode 100644 index 0000000..a32da2d --- /dev/null +++ b/src/features/crm/reports/server/datasets/catalog.ts @@ -0,0 +1,42 @@ +import { and, asc, eq } from 'drizzle-orm'; +import { crmReportDefinitions } from '@/db/schema'; +import { db } from '@/lib/db'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import type { ReportRegistryRow, ResolvedReportContext } from '../types'; + +export async function listReportRegistry( + context: ResolvedReportContext +): Promise { + const [rows, categoryOptions] = await Promise.all([ + db + .select() + .from(crmReportDefinitions) + .where( + and( + eq(crmReportDefinitions.organizationId, context.organizationId), + eq(crmReportDefinitions.isActive, true) + ) + ) + .orderBy(asc(crmReportDefinitions.category), asc(crmReportDefinitions.name)), + getActiveOptionsByCategory('crm_report_category', { + organizationId: context.organizationId + }) + ]); + const categoryMap = new Map( + categoryOptions.flatMap((item) => [ + [item.id, item.label] as const, + [item.code, item.label] as const + ]) + ); + + return rows.map((row) => ({ + id: row.id, + code: row.code, + name: row.name, + description: row.description, + category: row.category, + categoryLabel: categoryMap.get(row.category) ?? row.category, + isSystem: row.isSystem, + isActive: row.isActive + })); +} diff --git a/src/features/crm/reports/server/exports/service.ts b/src/features/crm/reports/server/exports/service.ts new file mode 100644 index 0000000..884eb87 --- /dev/null +++ b/src/features/crm/reports/server/exports/service.ts @@ -0,0 +1,44 @@ +import { auditAction } from '@/features/foundation/audit-log/service'; + +function escapeCsv(value: string | number | null | undefined) { + const text = value === null || value === undefined ? '' : String(value); + return `"${text.replaceAll('"', '""')}"`; +} + +export function buildReportCsv(rows: string[][]) { + return rows.map((row) => row.map((cell) => escapeCsv(cell)).join(',')).join('\n'); +} + +export function buildReportExcel(rows: string[][]) { + const body = rows + .map( + (row, rowIndex) => + `${row + .map((cell) => (rowIndex === 0 ? `${cell}` : `${cell}`)) + .join('')}` + ) + .join(''); + + return `${body}
`; +} + +export async function auditReportExport(input: { + organizationId: string; + userId: string; + reportCode: string; + format: 'csv' | 'xls'; + filters?: Record; +}) { + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: 'crm_report', + entityId: input.reportCode, + action: input.format === 'xls' ? 'export_excel' : 'export_csv', + afterData: { + reportCode: input.reportCode, + filters: input.filters ?? {}, + userId: input.userId + } + }); +} diff --git a/src/features/crm/reports/server/filters/shared.ts b/src/features/crm/reports/server/filters/shared.ts new file mode 100644 index 0000000..c8540f5 --- /dev/null +++ b/src/features/crm/reports/server/filters/shared.ts @@ -0,0 +1,71 @@ +import { asc, eq } from 'drizzle-orm'; +import { crmCustomers, memberships, users } from '@/db/schema'; +import { db } from '@/lib/db'; +import { getUserBranches } from '@/features/foundation/branch-scope/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; +import type { CrmReportFilterMetadata } from '../../api/types'; +import type { ResolvedReportContext } from '../types'; + +const PIPELINE_STAGE_OPTIONS = [ + { id: 'lead', code: 'lead', label: 'Lead' }, + { id: 'enquiry', code: 'enquiry', label: 'Enquiry' }, + { id: 'closed_won', code: 'closed_won', label: 'Won' }, + { id: 'closed_lost', code: 'closed_lost', label: 'Lost' } +] as const; + +const OUTCOME_OPTIONS = [ + { id: 'open', code: 'open', label: 'Open' }, + { id: 'won', code: 'won', label: 'Won' }, + { id: 'lost', code: 'lost', label: 'Lost' } +] as const; + +export async function buildSharedReportFilters( + context: ResolvedReportContext +): Promise { + const [branches, productTypes, leadSources, lostReasons, customers, memberRows] = await Promise.all([ + getUserBranches(), + getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }), + getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }), + getActiveOptionsByCategory('crm_lost_reason', { organizationId: context.organizationId }), + db + .select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name, branchId: crmCustomers.branchId }) + .from(crmCustomers) + .where(eq(crmCustomers.organizationId, context.organizationId)) + .orderBy(asc(crmCustomers.name)), + db + .select({ + userId: memberships.userId, + name: users.name + }) + .from(memberships) + .innerJoin(users, eq(users.id, memberships.userId)) + .where(eq(memberships.organizationId, context.organizationId)) + .orderBy(asc(users.name)) + ]); + + const visibleBranches = branches.filter((item) => hasBranchScopeAccess(context, item.id)); + const visibleProductTypes = productTypes.filter((item) => hasProductScopeAccess(context, item.id)); + const visibleCustomers = customers.filter((item) => hasBranchScopeAccess(context, item.branchId)); + const uniqueUsers = [...new Map(memberRows.map((row) => [row.userId, row])).values()]; + + return { + branches: visibleBranches.map((item) => ({ id: item.id, code: item.code, label: item.name })), + productTypes: visibleProductTypes.map((item) => ({ + id: item.id, + code: item.code, + label: item.label + })), + sales: uniqueUsers.map((item) => ({ id: item.userId, name: item.name })), + customers: visibleCustomers.map((item) => ({ + id: item.id, + code: item.code, + label: item.name + })), + customerOwners: uniqueUsers.map((item) => ({ id: item.userId, name: item.name })), + leadSources: leadSources.map((item) => ({ id: item.id, code: item.code, label: item.label })), + pipelineStages: PIPELINE_STAGE_OPTIONS.map((item) => ({ ...item })), + outcomes: OUTCOME_OPTIONS.map((item) => ({ ...item })), + lostReasons: lostReasons.map((item) => ({ id: item.id, code: item.code, label: item.label })) + }; +} diff --git a/src/features/crm/reports/server/service.ts b/src/features/crm/reports/server/service.ts new file mode 100644 index 0000000..dc3bf1d --- /dev/null +++ b/src/features/crm/reports/server/service.ts @@ -0,0 +1,78 @@ +import { auditAction } from '@/features/foundation/audit-log/service'; +import type { + CrmReportCatalogResponse, + CrmReportFiltersResponse, + CrmReportsResponse +} from '../api/types'; +import { buildReportCatalogGroups } from './builders/catalog-builder'; +import { resolveCrmAccess } from './context'; +import { listReportRegistry } from './datasets/catalog'; +import { buildSharedReportFilters } from './filters/shared'; +import type { ResolvedReportContext } from './types'; + +export function buildResolvedReportContext(input: Parameters[0]) { + return resolveCrmAccess(input); +} + +async function auditReportView(context: ResolvedReportContext, reportCode: string, filters?: Record) { + await auditAction({ + organizationId: context.organizationId, + userId: context.userId, + entityType: 'crm_report', + entityId: reportCode, + action: 'view', + afterData: { + reportCode, + filters: filters ?? {}, + userId: context.userId + } + }); +} + +export async function getCrmReportsResponse( + context: ResolvedReportContext +): Promise { + const items = await listReportRegistry(context); + const groups = buildReportCatalogGroups(items); + + await auditReportView(context, 'report_center'); + + return { + success: true, + time: new Date().toISOString(), + message: 'CRM reports loaded successfully', + items, + groups + }; +} + +export async function getCrmReportCatalogResponse( + context: ResolvedReportContext +): Promise { + const items = await listReportRegistry(context); + const groups = buildReportCatalogGroups(items); + + await auditReportView(context, 'report_catalog'); + + return { + success: true, + time: new Date().toISOString(), + message: 'CRM report catalog loaded successfully', + groups + }; +} + +export async function getCrmReportFiltersResponse( + context: ResolvedReportContext +): Promise { + const filters = await buildSharedReportFilters(context); + + await auditReportView(context, 'report_filters'); + + return { + success: true, + time: new Date().toISOString(), + message: 'CRM report filters loaded successfully', + filters + }; +} diff --git a/src/features/crm/reports/server/types.ts b/src/features/crm/reports/server/types.ts new file mode 100644 index 0000000..42f888f --- /dev/null +++ b/src/features/crm/reports/server/types.ts @@ -0,0 +1,25 @@ +export interface ResolvedReportContext { + organizationId: string; + userId: string; + membershipRole: string; + businessRole: string; + businessRoles: string[]; + permissions: string[]; + branchScopeIds: string[]; + productTypeScopeIds: string[]; + ownershipScope: string; + branchScopeMode: string; + productScopeMode: string; + approvalAuthority: string | null; +} + +export interface ReportRegistryRow { + id: string; + code: string; + name: string; + description: string | null; + category: string; + categoryLabel: string; + isSystem: boolean; + isActive: boolean; +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index a79e6d2..ee6c8a4 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -83,6 +83,8 @@ export const PERMISSIONS = { crmApprovalWorkflowDelete: 'crm.approval.workflow.delete', crmDashboardRead: 'crm.dashboard.read', crmDashboardExport: 'crm.dashboard.export', + crmReportRead: 'crm.report.read', + crmReportExport: 'crm.report.export', crmDocumentTemplateRead: 'crm.document_template.read', crmDocumentTemplateCreate: 'crm.document_template.create', crmDocumentTemplateUpdate: 'crm.document_template.update', @@ -152,7 +154,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record