From adde003fbaa468539b7683c882bf5e70aa6fa5b7 Mon Sep 17 00:00:00 2001 From: Wira Basalamah Date: Tue, 21 Apr 2026 09:29:29 +0700 Subject: [PATCH] chore: initial project import --- .env.example | 37 + .eslintrc.json | 3 + .github/workflows/ci-production-readiness.yml | 73 + .gitignore | 4 + INSTALL-UBUNTU-APP-ZAPPCARE.md | 430 + alert-policy.md | 60 + app/agent/contacts/[contactId]/page.tsx | 13 + app/agent/contacts/page.tsx | 48 + app/agent/inbox/mentioned/page.tsx | 25 + app/agent/inbox/page.tsx | 48 + app/agent/inbox/resolved/page.tsx | 17 + app/agent/inbox/unassigned/page.tsx | 31 + app/agent/page.tsx | 18 + app/agent/performance/page.tsx | 13 + app/agent/quick-tools/page.tsx | 52 + app/api/health/route.ts | 160 + app/api/jobs/campaign-retry/route.ts | 133 + app/api/webhooks/whatsapp/route.ts | 805 ++ app/audit-log/page.tsx | 45 + app/auth/login/route.ts | 129 + app/auth/logout/route.ts | 26 + app/billing/history/page.tsx | 59 + app/billing/invoices/[invoiceId]/page.tsx | 115 + app/billing/page.tsx | 13 + app/campaigns/[campaignId]/page.tsx | 71 + .../[campaignId]/recipients/page.tsx | 45 + app/campaigns/new/page.tsx | 87 + app/campaigns/page.tsx | 71 + app/campaigns/review/page.tsx | 119 + app/contacts/[contactId]/edit/page.tsx | 89 + app/contacts/[contactId]/page.tsx | 73 + app/contacts/export/page.tsx | 73 + app/contacts/import/page.tsx | 95 + app/contacts/new/page.tsx | 69 + app/contacts/page.tsx | 60 + app/contacts/segments/[segmentId]/page.tsx | 108 + app/contacts/segments/new/page.tsx | 45 + app/contacts/segments/page.tsx | 77 + app/dashboard/page.tsx | 18 + app/forgot-password/page.tsx | 165 + app/globals.css | 43 + app/inbox/page.tsx | 54 + app/invite/[token]/page.tsx | 147 + app/layout.tsx | 22 + app/locale/route.ts | 28 + app/login/page.tsx | 121 + app/notifications/page.tsx | 83 + app/page.tsx | 5 + app/profile/change-password/page.tsx | 50 + app/profile/edit/page.tsx | 43 + app/profile/page.tsx | 43 + app/reports/agent-productivity/page.tsx | 13 + app/reports/campaign-analytics/page.tsx | 13 + app/reports/contact-growth/page.tsx | 13 + app/reports/page.tsx | 25 + app/reports/resolution/page.tsx | 13 + app/reports/response-time/page.tsx | 13 + app/reset-password/page.tsx | 163 + app/search/page.tsx | 132 + app/settings/auto-assignment/page.tsx | 51 + app/settings/business-hours/page.tsx | 47 + app/settings/canned-responses/page.tsx | 46 + app/settings/integrations/page.tsx | 84 + app/settings/page.tsx | 21 + app/settings/profile/page.tsx | 85 + app/settings/tags/page.tsx | 46 + app/super-admin/alerts/page.tsx | 84 + app/super-admin/audit-log/page.tsx | 45 + .../billing/invoices/[invoiceId]/page.tsx | 115 + app/super-admin/billing/invoices/page.tsx | 60 + app/super-admin/billing/plans/page.tsx | 40 + .../billing/subscriptions/page.tsx | 55 + app/super-admin/channels/[channelId]/page.tsx | 96 + app/super-admin/channels/page.tsx | 52 + app/super-admin/page.tsx | 18 + app/super-admin/reports/page.tsx | 13 + app/super-admin/security-events/page.tsx | 45 + app/super-admin/settings/page.tsx | 100 + .../tenants/[tenantId]/channels/new/page.tsx | 73 + .../tenants/[tenantId]/edit/page.tsx | 93 + app/super-admin/tenants/[tenantId]/page.tsx | 161 + app/super-admin/tenants/new/page.tsx | 66 + app/super-admin/tenants/page.tsx | 22 + app/super-admin/webhook-logs/page.tsx | 43 + app/team/[userId]/edit/page.tsx | 84 + app/team/[userId]/page.tsx | 58 + app/team/new/page.tsx | 66 + app/team/page.tsx | 64 + app/team/performance/page.tsx | 90 + app/templates/[templateId]/edit/page.tsx | 70 + app/templates/[templateId]/page.tsx | 54 + app/templates/new/page.tsx | 60 + app/templates/page.tsx | 62 + app/unauthorized/page.tsx | 22 + campaign-retry-job.md | 177 + components/app-shell.tsx | 176 + components/page-templates.tsx | 93 + components/placeholders.tsx | 428 + components/ui.tsx | 109 + komponen-ui-checklist-whatsapp-inbox.md | 517 ++ lib/admin-crud.ts | 1432 ++++ lib/audit.ts | 50 + lib/auth-tokens.ts | 200 + lib/auth.ts | 437 + lib/campaign-dispatch-service.ts | 685 ++ lib/campaign-utils.ts | 79 + lib/demo-data.ts | 413 + lib/i18n.ts | 225 + lib/inbox-ops.ts | 1001 +++ lib/job-alerts.ts | 52 + lib/mock-data.ts | 71 + lib/notification.ts | 154 + lib/permissions.ts | 71 + lib/platform-data.ts | 8 + lib/prisma.ts | 11 + lib/rate-limit.ts | 94 + lib/whatsapp-provider.ts | 186 + middleware.ts | 65 + next-env.d.ts | 5 + next.config.ts | 62 + ops-runbook.md | 91 + package-lock.json | 6996 +++++++++++++++++ package.json | 48 + postcss.config.js | 6 + .../20260420143000_init/migration.sql | 380 + .../migration.sql | 4 + .../migration.sql | 22 + .../migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 17 + prisma/schema.prisma | 570 ++ prisma/seed.cjs | 589 ++ production-readiness-checklist.md | 63 + public/logo_zappcare.png | Bin 0 -> 4444 bytes route-map-nextjs-whatsapp-inbox.md | 402 + screen-flow-whatsapp-inbox.md | 502 ++ .../code.html | 415 + .../screen.png | Bin 0 -> 226310 bytes screen_design/aero_logic/DESIGN.md | 90 + .../code.html | 398 + .../screen.png | Bin 0 -> 398875 bytes .../connect_channel_super_admin/code.html | 306 + .../connect_channel_super_admin/screen.png | Bin 0 -> 437941 bytes .../code.html | 165 + .../screen.png | Bin 0 -> 230854 bytes .../login_zappcare_website/code.html | 165 + .../login_zappcare_website/screen.png | Bin 0 -> 420926 bytes screen_design/logo_zappcare.png | Bin 0 -> 4444 bytes .../reset_password_zappcare_website/code.html | 180 + .../screen.png | Bin 0 -> 172687 bytes .../shared_inbox_zappcare_website/code.html | 400 + .../shared_inbox_zappcare_website/screen.png | Bin 0 -> 339233 bytes .../code.html | 345 + .../screen.png | Bin 0 -> 368423 bytes .../system_audit_log_super_admin/code.html | 422 + .../system_audit_log_super_admin/screen.png | Bin 0 -> 314424 bytes .../code.html | 485 ++ .../screen.png | Bin 0 -> 272402 bytes .../zappcare_accept_invitation/code.html | 188 + .../zappcare_accept_invitation/screen.png | Bin 0 -> 209273 bytes .../zappcare_access_denied/code.html | 158 + .../zappcare_access_denied/screen.png | Bin 0 -> 176523 bytes .../zappcare_agent_dashboard/code.html | 424 + .../zappcare_agent_dashboard/screen.png | Bin 0 -> 415307 bytes .../zappcare_api_webhook_logs/code.html | 566 ++ .../zappcare_api_webhook_logs/screen.png | Bin 0 -> 362409 bytes .../zappcare_billing_subscription/code.html | 352 + .../zappcare_billing_subscription/screen.png | Bin 0 -> 221340 bytes .../zappcare_campaign_recipients/code.html | 498 ++ .../zappcare_campaign_recipients/screen.png | Bin 0 -> 357969 bytes .../zappcare_change_password/code.html | 315 + .../zappcare_change_password/screen.png | Bin 0 -> 428227 bytes .../zappcare_channel_health_detail/code.html | 459 ++ .../zappcare_channel_health_detail/screen.png | Bin 0 -> 344190 bytes .../zappcare_check_your_email/code.html | 155 + .../zappcare_check_your_email/screen.png | Bin 0 -> 217585 bytes .../code.html | 329 + .../screen.png | Bin 0 -> 493070 bytes .../zappcare_create_tenant/code.html | 357 + .../zappcare_create_tenant/screen.png | Bin 0 -> 269609 bytes screen_design/zappcare_edit_profile/code.html | 240 + .../zappcare_edit_profile/screen.png | Bin 0 -> 223362 bytes .../code.html | 147 + .../screen.png | Bin 0 -> 211286 bytes .../zappcare_global_search/code.html | 308 + .../zappcare_global_search/screen.png | Bin 0 -> 208025 bytes .../zappcare_import_contacts/code.html | 305 + .../zappcare_import_contacts/screen.png | Bin 0 -> 324187 bytes screen_design/zappcare_my_profile/code.html | 286 + screen_design/zappcare_my_profile/screen.png | Bin 0 -> 305613 bytes .../zappcare_no_data_found/code.html | 262 + .../zappcare_no_data_found/screen.png | Bin 0 -> 177197 bytes .../zappcare_notifications/code.html | 274 + .../zappcare_notifications/screen.png | Bin 0 -> 203048 bytes .../zappcare_reports_overview/code.html | 432 + .../zappcare_reports_overview/screen.png | Bin 0 -> 266552 bytes .../zappcare_super_admin_dashboard/code.html | 348 + .../zappcare_super_admin_dashboard/screen.png | Bin 0 -> 313412 bytes .../zappcare_team_management_1/code.html | 349 + .../zappcare_team_management_1/screen.png | Bin 0 -> 257052 bytes .../zappcare_team_management_2/code.html | 363 + .../zappcare_team_management_2/screen.png | Bin 0 -> 236774 bytes .../zappcare_template_management/code.html | 441 ++ .../zappcare_template_management/screen.png | Bin 0 -> 449933 bytes .../zappcare_tenant_settings/code.html | 300 + .../zappcare_tenant_settings/screen.png | Bin 0 -> 333920 bytes .../zappcare_unassigned_queue/code.html | 350 + .../zappcare_unassigned_queue/screen.png | Bin 0 -> 328687 bytes scripts/campaign-retry-daemon.mjs | 159 + scripts/campaign-retry-job.mjs | 56 + scripts/ops-healthcheck.mjs | 123 + scripts/ops-incident.mjs | 85 + scripts/ops-maintenance.mjs | 122 + scripts/ops-readiness.mjs | 194 + scripts/ops-smoke.mjs | 45 + struktur-screen-whatsapp-inbox.md | 871 ++ tailwind.config.ts | 95 + tsconfig.check.json | 5 + tsconfig.check.tsbuildinfo | 1 + tsconfig.json | 40 + tsconfig.tsbuildinfo | 1 + wireframe-text-low-fidelity-whatsapp-inbox.md | 950 +++ 222 files changed, 37657 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .github/workflows/ci-production-readiness.yml create mode 100644 .gitignore create mode 100644 INSTALL-UBUNTU-APP-ZAPPCARE.md create mode 100644 alert-policy.md create mode 100644 app/agent/contacts/[contactId]/page.tsx create mode 100644 app/agent/contacts/page.tsx create mode 100644 app/agent/inbox/mentioned/page.tsx create mode 100644 app/agent/inbox/page.tsx create mode 100644 app/agent/inbox/resolved/page.tsx create mode 100644 app/agent/inbox/unassigned/page.tsx create mode 100644 app/agent/page.tsx create mode 100644 app/agent/performance/page.tsx create mode 100644 app/agent/quick-tools/page.tsx create mode 100644 app/api/health/route.ts create mode 100644 app/api/jobs/campaign-retry/route.ts create mode 100644 app/api/webhooks/whatsapp/route.ts create mode 100644 app/audit-log/page.tsx create mode 100644 app/auth/login/route.ts create mode 100644 app/auth/logout/route.ts create mode 100644 app/billing/history/page.tsx create mode 100644 app/billing/invoices/[invoiceId]/page.tsx create mode 100644 app/billing/page.tsx create mode 100644 app/campaigns/[campaignId]/page.tsx create mode 100644 app/campaigns/[campaignId]/recipients/page.tsx create mode 100644 app/campaigns/new/page.tsx create mode 100644 app/campaigns/page.tsx create mode 100644 app/campaigns/review/page.tsx create mode 100644 app/contacts/[contactId]/edit/page.tsx create mode 100644 app/contacts/[contactId]/page.tsx create mode 100644 app/contacts/export/page.tsx create mode 100644 app/contacts/import/page.tsx create mode 100644 app/contacts/new/page.tsx create mode 100644 app/contacts/page.tsx create mode 100644 app/contacts/segments/[segmentId]/page.tsx create mode 100644 app/contacts/segments/new/page.tsx create mode 100644 app/contacts/segments/page.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/forgot-password/page.tsx create mode 100644 app/globals.css create mode 100644 app/inbox/page.tsx create mode 100644 app/invite/[token]/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/locale/route.ts create mode 100644 app/login/page.tsx create mode 100644 app/notifications/page.tsx create mode 100644 app/page.tsx create mode 100644 app/profile/change-password/page.tsx create mode 100644 app/profile/edit/page.tsx create mode 100644 app/profile/page.tsx create mode 100644 app/reports/agent-productivity/page.tsx create mode 100644 app/reports/campaign-analytics/page.tsx create mode 100644 app/reports/contact-growth/page.tsx create mode 100644 app/reports/page.tsx create mode 100644 app/reports/resolution/page.tsx create mode 100644 app/reports/response-time/page.tsx create mode 100644 app/reset-password/page.tsx create mode 100644 app/search/page.tsx create mode 100644 app/settings/auto-assignment/page.tsx create mode 100644 app/settings/business-hours/page.tsx create mode 100644 app/settings/canned-responses/page.tsx create mode 100644 app/settings/integrations/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/settings/profile/page.tsx create mode 100644 app/settings/tags/page.tsx create mode 100644 app/super-admin/alerts/page.tsx create mode 100644 app/super-admin/audit-log/page.tsx create mode 100644 app/super-admin/billing/invoices/[invoiceId]/page.tsx create mode 100644 app/super-admin/billing/invoices/page.tsx create mode 100644 app/super-admin/billing/plans/page.tsx create mode 100644 app/super-admin/billing/subscriptions/page.tsx create mode 100644 app/super-admin/channels/[channelId]/page.tsx create mode 100644 app/super-admin/channels/page.tsx create mode 100644 app/super-admin/page.tsx create mode 100644 app/super-admin/reports/page.tsx create mode 100644 app/super-admin/security-events/page.tsx create mode 100644 app/super-admin/settings/page.tsx create mode 100644 app/super-admin/tenants/[tenantId]/channels/new/page.tsx create mode 100644 app/super-admin/tenants/[tenantId]/edit/page.tsx create mode 100644 app/super-admin/tenants/[tenantId]/page.tsx create mode 100644 app/super-admin/tenants/new/page.tsx create mode 100644 app/super-admin/tenants/page.tsx create mode 100644 app/super-admin/webhook-logs/page.tsx create mode 100644 app/team/[userId]/edit/page.tsx create mode 100644 app/team/[userId]/page.tsx create mode 100644 app/team/new/page.tsx create mode 100644 app/team/page.tsx create mode 100644 app/team/performance/page.tsx create mode 100644 app/templates/[templateId]/edit/page.tsx create mode 100644 app/templates/[templateId]/page.tsx create mode 100644 app/templates/new/page.tsx create mode 100644 app/templates/page.tsx create mode 100644 app/unauthorized/page.tsx create mode 100644 campaign-retry-job.md create mode 100644 components/app-shell.tsx create mode 100644 components/page-templates.tsx create mode 100644 components/placeholders.tsx create mode 100644 components/ui.tsx create mode 100644 komponen-ui-checklist-whatsapp-inbox.md create mode 100644 lib/admin-crud.ts create mode 100644 lib/audit.ts create mode 100644 lib/auth-tokens.ts create mode 100644 lib/auth.ts create mode 100644 lib/campaign-dispatch-service.ts create mode 100644 lib/campaign-utils.ts create mode 100644 lib/demo-data.ts create mode 100644 lib/i18n.ts create mode 100644 lib/inbox-ops.ts create mode 100644 lib/job-alerts.ts create mode 100644 lib/mock-data.ts create mode 100644 lib/notification.ts create mode 100644 lib/permissions.ts create mode 100644 lib/platform-data.ts create mode 100644 lib/prisma.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/whatsapp-provider.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 ops-runbook.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/migrations/20260420143000_init/migration.sql create mode 100644 prisma/migrations/20260421093000_add_campaign_retry_metadata/migration.sql create mode 100644 prisma/migrations/20260421110000_add_campaign_retry_job_state_and_indexes/migration.sql create mode 100644 prisma/migrations/20260421133000_extend_campaign_retry_state_for_alerts/migration.sql create mode 100644 prisma/migrations/20260422090000_add_webhook_event_hash/migration.sql create mode 100644 prisma/migrations/20260423000000_add_auth_tokens/migration.sql create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.cjs create mode 100644 production-readiness-checklist.md create mode 100644 public/logo_zappcare.png create mode 100644 route-map-nextjs-whatsapp-inbox.md create mode 100644 screen-flow-whatsapp-inbox.md create mode 100644 screen_design/admin_dashboard_zappcare_website/code.html create mode 100644 screen_design/admin_dashboard_zappcare_website/screen.png create mode 100644 screen_design/aero_logic/DESIGN.md create mode 100644 screen_design/broadcast_campaign_creator_zappcare_website/code.html create mode 100644 screen_design/broadcast_campaign_creator_zappcare_website/screen.png create mode 100644 screen_design/connect_channel_super_admin/code.html create mode 100644 screen_design/connect_channel_super_admin/screen.png create mode 100644 screen_design/forgot_password_zappcare_website/code.html create mode 100644 screen_design/forgot_password_zappcare_website/screen.png create mode 100644 screen_design/login_zappcare_website/code.html create mode 100644 screen_design/login_zappcare_website/screen.png create mode 100644 screen_design/logo_zappcare.png create mode 100644 screen_design/reset_password_zappcare_website/code.html create mode 100644 screen_design/reset_password_zappcare_website/screen.png create mode 100644 screen_design/shared_inbox_zappcare_website/code.html create mode 100644 screen_design/shared_inbox_zappcare_website/screen.png create mode 100644 screen_design/subscription_catalog_super_admin/code.html create mode 100644 screen_design/subscription_catalog_super_admin/screen.png create mode 100644 screen_design/system_audit_log_super_admin/code.html create mode 100644 screen_design/system_audit_log_super_admin/screen.png create mode 100644 screen_design/tenant_management_zappcare_website/code.html create mode 100644 screen_design/tenant_management_zappcare_website/screen.png create mode 100644 screen_design/zappcare_accept_invitation/code.html create mode 100644 screen_design/zappcare_accept_invitation/screen.png create mode 100644 screen_design/zappcare_access_denied/code.html create mode 100644 screen_design/zappcare_access_denied/screen.png create mode 100644 screen_design/zappcare_agent_dashboard/code.html create mode 100644 screen_design/zappcare_agent_dashboard/screen.png create mode 100644 screen_design/zappcare_api_webhook_logs/code.html create mode 100644 screen_design/zappcare_api_webhook_logs/screen.png create mode 100644 screen_design/zappcare_billing_subscription/code.html create mode 100644 screen_design/zappcare_billing_subscription/screen.png create mode 100644 screen_design/zappcare_campaign_recipients/code.html create mode 100644 screen_design/zappcare_campaign_recipients/screen.png create mode 100644 screen_design/zappcare_change_password/code.html create mode 100644 screen_design/zappcare_change_password/screen.png create mode 100644 screen_design/zappcare_channel_health_detail/code.html create mode 100644 screen_design/zappcare_channel_health_detail/screen.png create mode 100644 screen_design/zappcare_check_your_email/code.html create mode 100644 screen_design/zappcare_check_your_email/screen.png create mode 100644 screen_design/zappcare_create_template_request/code.html create mode 100644 screen_design/zappcare_create_template_request/screen.png create mode 100644 screen_design/zappcare_create_tenant/code.html create mode 100644 screen_design/zappcare_create_tenant/screen.png create mode 100644 screen_design/zappcare_edit_profile/code.html create mode 100644 screen_design/zappcare_edit_profile/screen.png create mode 100644 screen_design/zappcare_forgot_password_success/code.html create mode 100644 screen_design/zappcare_forgot_password_success/screen.png create mode 100644 screen_design/zappcare_global_search/code.html create mode 100644 screen_design/zappcare_global_search/screen.png create mode 100644 screen_design/zappcare_import_contacts/code.html create mode 100644 screen_design/zappcare_import_contacts/screen.png create mode 100644 screen_design/zappcare_my_profile/code.html create mode 100644 screen_design/zappcare_my_profile/screen.png create mode 100644 screen_design/zappcare_no_data_found/code.html create mode 100644 screen_design/zappcare_no_data_found/screen.png create mode 100644 screen_design/zappcare_notifications/code.html create mode 100644 screen_design/zappcare_notifications/screen.png create mode 100644 screen_design/zappcare_reports_overview/code.html create mode 100644 screen_design/zappcare_reports_overview/screen.png create mode 100644 screen_design/zappcare_super_admin_dashboard/code.html create mode 100644 screen_design/zappcare_super_admin_dashboard/screen.png create mode 100644 screen_design/zappcare_team_management_1/code.html create mode 100644 screen_design/zappcare_team_management_1/screen.png create mode 100644 screen_design/zappcare_team_management_2/code.html create mode 100644 screen_design/zappcare_team_management_2/screen.png create mode 100644 screen_design/zappcare_template_management/code.html create mode 100644 screen_design/zappcare_template_management/screen.png create mode 100644 screen_design/zappcare_tenant_settings/code.html create mode 100644 screen_design/zappcare_tenant_settings/screen.png create mode 100644 screen_design/zappcare_unassigned_queue/code.html create mode 100644 screen_design/zappcare_unassigned_queue/screen.png create mode 100644 scripts/campaign-retry-daemon.mjs create mode 100644 scripts/campaign-retry-job.mjs create mode 100644 scripts/ops-healthcheck.mjs create mode 100644 scripts/ops-incident.mjs create mode 100644 scripts/ops-maintenance.mjs create mode 100644 scripts/ops-readiness.mjs create mode 100755 scripts/ops-smoke.mjs create mode 100644 struktur-screen-whatsapp-inbox.md create mode 100644 tailwind.config.ts create mode 100644 tsconfig.check.json create mode 100644 tsconfig.check.tsbuildinfo create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo create mode 100644 wireframe-text-low-fidelity-whatsapp-inbox.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d0a36bd --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +DATABASE_URL="file:./dev.db" +AUTH_SECRET="change-me" +WHATSAPP_API_TOKEN="your-meta-token" +WHATSAPP_API_VERSION="v22.0" +WHATSAPP_WEBHOOK_VERIFY_TOKEN="your-webhook-verify-token" +WHATSAPP_WEBHOOK_SECRET="your-webhook-secret" +WHATSAPP_ALLOW_SIMULATED_SEND="true" +APP_URL="http://localhost:3000" +CAMPAIGN_RETRY_JOB_TOKEN="change-me-for-production" +CAMPAIGN_RETRY_BATCH_SIZE="100" +CAMPAIGN_RETRY_MAX_CAMPAIGNS="20" +CAMPAIGN_RETRY_JOB_LOCK_TTL_SECONDS="300" +CAMPAIGN_RETRY_ALERT_WEBHOOK_URL="" +CAMPAIGN_RETRY_ALERT_ON_FAILURE="true" +HEALTHCHECK_TOKEN="" +OPS_BASE_URL="" +WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR="20" +RETRY_WORKER_STALE_MINUTES="30" +CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS="300" +CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS="30000" +LOGIN_RATE_LIMIT_ATTEMPTS="10" +LOGIN_RATE_LIMIT_WINDOW_MS="900000" +CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET="60" +CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST="20" +CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS="60000" +WHATSAPP_WEBHOOK_RATE_LIMIT_GET="60" +WHATSAPP_WEBHOOK_RATE_LIMIT_POST="120" +WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS="60000" +AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24" +CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120" +WEBHOOK_EVENT_RETENTION_DAYS="30" +AUDIT_LOG_RETENTION_DAYS="365" + +# Background job (campaign retry) +CAMPAIGN_RETRY_JOB_URL="http://localhost:3000" +CAMPAIGN_RETRY_TENANT_ID="" +CAMPAIGN_RETRY_CAMPAIGN_ID="" diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.github/workflows/ci-production-readiness.yml b/.github/workflows/ci-production-readiness.yml new file mode 100644 index 0000000..4212e82 --- /dev/null +++ b/.github/workflows/ci-production-readiness.yml @@ -0,0 +1,73 @@ +name: CI - Production Readiness + +on: + push: + pull_request: + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + env: + DATABASE_URL: "file:./.ci.sqlite" + AUTH_SECRET: "whatsapp-inbox-ci-secret" + NEXT_PUBLIC_APP_URL: "http://127.0.0.1:3000" + APP_URL: "http://127.0.0.1:3000" + OPS_BASE_URL: "http://127.0.0.1:3000" + CAMPAIGN_RETRY_JOB_TOKEN: "ci-campaign-retry-token" + HEALTHCHECK_TOKEN: "ci-health-token" + WHATSAPP_WEBHOOK_VERIFY_TOKEN: "ci-verify-token" + WHATSAPP_WEBHOOK_SECRET: "ci-webhook-secret" + RETRY_WORKER_STALE_MINUTES: "15" + WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR: "99" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Prepare database + run: npm run db:deploy + + - name: Static verification + run: npm run ci:verify + + - name: Start app + run: npm run start -- --hostname 127.0.0.1 --port 3000 > /tmp/ci-app.log 2>&1 & + + - name: Run ops health checks + run: | + for i in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:3000/api/health?token=$HEALTHCHECK_TOKEN" >/dev/null; then + break + fi + if [ "$i" -eq 40 ]; then + echo "App not ready for health check" + tail -n 120 /tmp/ci-app.log + exit 1 + fi + sleep 2 + done + + APP_URL=http://127.0.0.1:3000 \ + NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 \ + OPS_BASE_URL=http://127.0.0.1:3000 \ + HEALTHCHECK_TOKEN=$HEALTHCHECK_TOKEN \ + CAMPAIGN_RETRY_JOB_TOKEN=$CAMPAIGN_RETRY_JOB_TOKEN \ + npm run ops:healthcheck + + APP_URL=http://127.0.0.1:3000 \ + NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 \ + OPS_BASE_URL=http://127.0.0.1:3000 \ + HEALTHCHECK_TOKEN=$HEALTHCHECK_TOKEN \ + CAMPAIGN_RETRY_JOB_TOKEN=$CAMPAIGN_RETRY_JOB_TOKEN \ + npm run ops:readiness + - name: Print app log on failure + if: failure() + run: tail -n 200 /tmp/ci-app.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96e704c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.next +node_modules +dev.db +dev.db-journal diff --git a/INSTALL-UBUNTU-APP-ZAPPCARE.md b/INSTALL-UBUNTU-APP-ZAPPCARE.md new file mode 100644 index 0000000..48fcf35 --- /dev/null +++ b/INSTALL-UBUNTU-APP-ZAPPCARE.md @@ -0,0 +1,430 @@ +# Panduan Install WhatsApp Inbox di Ubuntu (Domain: app.zappcare.id) + +Dokumen ini menyusun langkah deployment lengkap untuk server Ubuntu dengan kondisi: + +- PostgreSQL sudah terinstall +- Nginx sudah terinstall +- Gitea berjalan di port `3001` +- Aplikasi ini tidak boleh pakai port `3000` karena dipakai layanan lain + +Pada panduan ini, aplikasi akan jalan di **port `3002`** di loopback (`127.0.0.1:3002`) dan di-serve via Nginx ke domain `app.zappcare.id`. + +## 1) Prasyarat + +Pastikan server sudah memenuhi: + +- Ubuntu 22.04/24.04 (sesuaikan) +- User dengan sudo +- PostgreSQL aktif +- Nginx aktif +- Node.js 20.x + npm +- Git +- domain `app.zappcare.id` mengarah ke IP server (DNS A record) + +## 2) Install runtime (jika belum) + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y curl ca-certificates git nginx postgresql postgresql-contrib + +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs + +node -v +npm -v +``` + +## 3) Buat database PostgreSQL + +Masuk psql: + +```bash +sudo -u postgres psql +``` + +Jalankan: + +```sql +CREATE USER whatsapp_inbox WITH PASSWORD 'GANTI_PASSWORD_KUAT'; +CREATE DATABASE whatsapp_inbox OWNER whatsapp_inbox; +\q +``` + +## 4) Setup user deploy + +```bash +sudo useradd --system --home /var/www/whatsapp-inbox --shell /usr/sbin/nologin whatsapp-inbox || true +sudo mkdir -p /var/www/whatsapp-inbox +sudo chown -R whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox +``` + +## 5) Clone source & install dependency + +```bash +sudo -u whatsapp-inbox git clone /var/www/whatsapp-inbox +cd /var/www/whatsapp-inbox +sudo -u whatsapp-inbox npm ci +``` + +> Ganti `` dengan URL repo Anda. + +## 6) Buat `.env` production + +Buat file dari template: + +```bash +sudo cp /var/www/whatsapp-inbox/.env.example /var/www/whatsapp-inbox/.env +sudo chown whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox/.env +sudo -u whatsapp-inbox nano /var/www/whatsapp-inbox/.env +``` + +Isi `.env` yang wajib: + +```env +NODE_ENV=production +PORT=3002 +HOST=127.0.0.1 + +DATABASE_URL="postgresql://whatsapp_inbox:GANTI_PASSWORD_KUAT@127.0.0.1:5432/whatsapp_inbox?schema=public" +AUTH_SECRET="ganti_secret_acak_minimal_32_karakter" + +APP_URL="https://app.zappcare.id" +NEXT_PUBLIC_APP_URL="https://app.zappcare.id" +OPS_BASE_URL="https://app.zappcare.id" + +CAMPAIGN_RETRY_JOB_URL="https://app.zappcare.id" +CAMPAIGN_RETRY_JOB_TOKEN="ganti_token_acak" +HEALTHCHECK_TOKEN="ganti_token_health" + +WHATSAPP_WEBHOOK_VERIFY_TOKEN="token_verify_webhook_anda" +WHATSAPP_WEBHOOK_SECRET="webhook_secret_anda" +WHATSAPP_API_TOKEN="meta_whatsapp_api_token" +WHATSAPP_API_VERSION="v22.0" +WHATSAPP_ALLOW_SIMULATED_SEND="false" + +CAMPAIGN_RETRY_BATCH_SIZE="100" +CAMPAIGN_RETRY_MAX_CAMPAIGNS="20" +CAMPAIGN_RETRY_JOB_LOCK_TTL_SECONDS="300" +CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS="300" +CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS="30000" +CAMPAIGN_RETRY_ALERT_ON_FAILURE="true" +CAMPAIGN_RETRY_ALERT_WEBHOOK_URL="" + +WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR="20" +RETRY_WORKER_STALE_MINUTES="30" +LOGIN_RATE_LIMIT_ATTEMPTS="10" +LOGIN_RATE_LIMIT_WINDOW_MS="900000" +CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET="60" +CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST="20" +CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS="60000" +WHATSAPP_WEBHOOK_RATE_LIMIT_GET="60" +WHATSAPP_WEBHOOK_RATE_LIMIT_POST="120" +WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS="60000" + +AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24" +CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120" +WEBHOOK_EVENT_RETENTION_DAYS="30" +AUDIT_LOG_RETENTION_DAYS="365" +``` + +> Pastikan nilai `DATABASE_URL` sesuai username/password/password database Anda. + +## 7) Pastikan Prisma pakai PostgreSQL + +### Cek `schema.prisma` + +Buka `prisma/schema.prisma` dan pastikan datasource: + +```prisma +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} +``` + +Jika masih `sqlite`, migrasi ke Postgres harus dibangun ulang. +Catatan: perubahan provider dan migration bisa dilakukan di environment staging/dev sebelum deploy production. + +### Jalankan migration + seed + +```bash +cd /var/www/whatsapp-inbox +sudo -u whatsapp-inbox npm run db:deploy +sudo -u whatsapp-inbox npm run db:seed +``` + +## 8) Build & verifikasi + +```bash +cd /var/www/whatsapp-inbox +sudo -u whatsapp-inbox npm run ci:verify +``` + +Jika berhasil, lanjut ke service. + +## 9) Test manual port 3002 + +```bash +cd /var/www/whatsapp-inbox +sudo -u whatsapp-inbox npm run start -- --hostname 127.0.0.1 --port 3002 +``` + +Di terminal lain: + +```bash +curl -I http://127.0.0.1:3002 +curl -s http://127.0.0.1:3002/api/health +``` + +## 10) Buat service systemd (Next.js app) + +Buat file `/etc/systemd/system/whatsapp-inbox.service`: + +```ini +[Unit] +Description=WhatsApp Inbox (Next.js App) +After=network.target postgresql.service + +[Service] +Type=simple +User=whatsapp-inbox +Group=whatsapp-inbox +WorkingDirectory=/var/www/whatsapp-inbox +EnvironmentFile=/var/www/whatsapp-inbox/.env +ExecStart=/usr/bin/npm run start -- --hostname 127.0.0.1 --port 3002 +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +``` + +Enable dan start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now whatsapp-inbox +sudo systemctl status whatsapp-inbox +``` + +## 11) Service retry worker (daemon) + +Buat file `/etc/systemd/system/whatsapp-inbox-retry.service`: + +```ini +[Unit] +Description=WhatsApp Inbox Campaign Retry Daemon +After=network.target whatsapp-inbox.service + +[Service] +Type=simple +User=whatsapp-inbox +Group=whatsapp-inbox +WorkingDirectory=/var/www/whatsapp-inbox +EnvironmentFile=/var/www/whatsapp-inbox/.env +ExecStart=/usr/bin/npm run job:campaign-retry:daemon +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now whatsapp-inbox-retry +sudo systemctl status whatsapp-inbox-retry +``` + +## 12) Konfigurasi Nginx reverse proxy + +Buat file `/etc/nginx/sites-available/app.zappcare.id`: + +```nginx +server { + listen 80; + server_name app.zappcare.id; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name app.zappcare.id; + + client_max_body_size 20m; + proxy_buffering off; + + ssl_certificate /etc/letsencrypt/live/app.zappcare.id/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app.zappcare.id/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } +} +``` + +Aktifkan site dan reload: + +```bash +sudo ln -s /etc/nginx/sites-available/app.zappcare.id /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +## 13) Install SSL (Let's Encrypt) + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d app.zappcare.id +``` + +## 14) Cek akhir deployment + +```bash +curl -I https://app.zappcare.id +curl -I https://app.zappcare.id/api/health +curl -s https://app.zappcare.id/api/health | jq +``` + +Ops check: + +```bash +APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:readiness +APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:smoke +``` + +## 15) Update rutin + +```bash +cd /var/www/whatsapp-inbox +git pull origin main +sudo -u whatsapp-inbox npm ci +sudo -u whatsapp-inbox npm run ci:verify +sudo -u whatsapp-inbox npm run db:deploy +sudo systemctl restart whatsapp-inbox +sudo systemctl restart whatsapp-inbox-retry +``` + +## 16) Rollback cepat + +```bash +cd /var/www/whatsapp-inbox +git log --oneline -n 10 +git checkout +sudo systemctl restart whatsapp-inbox +``` + +## 17) Hal penting untuk environment produksi + +- Pastikan semua token tidak lagi `change-me`/`your-*`. +- Monitor `ops:readiness` + `ops:smoke` tiap hari / saat deploy. +- Jalankan `ops:maintenance` berkala (cron harian/mingguan). +- Pastikan job retry tetap hidup: + - `sudo systemctl status whatsapp-inbox-retry` + +```bash +sudo systemctl status whatsapp-inbox +sudo systemctl status whatsapp-inbox-retry +journalctl -u whatsapp-inbox -f +journalctl -u whatsapp-inbox-retry -f +``` + +## 18) Troubleshooting cepat + +### 18.1 Domain menjawab 502 Bad Gateway + +```bash +sudo systemctl status whatsapp-inbox +sudo ss -ltnp | rg "127.0.0.1:3002" +sudo nginx -t +sudo systemctl reload nginx +``` + +- Jika service app tidak jalan, cek log: + `journalctl -u whatsapp-inbox -n 200 --no-pager` +- Pastikan Nginx proxy ke `127.0.0.1:3002` (bukan 3000/3001). +- Jika app jalan, cek `.env` dan `PORT=3002`. + +### 18.2 Halaman health 500 / tidak bisa start + +```bash +cd /var/www/whatsapp-inbox +sudo -u whatsapp-inbox npm run ops:readiness +``` + +- Jika `DATABASE_URL` error: + - cek service PostgreSQL: `sudo systemctl status postgresql` + - cek koneksi manual: `psql "postgresql://whatsapp_inbox:GANTI_PASSWORD_KUAT@127.0.0.1:5432/whatsapp_inbox?schema=public" -c '\dt'` +- Jika token/secret bermasalah: + - cek value `AUTH_SECRET`, `CAMPAIGN_RETRY_JOB_TOKEN`, `WHATSAPP_WEBHOOK_SECRET` + - jangan ada placeholder seperti `change-me`, `your-*` + +### 18.3 Retry job tidak berjalan + +```bash +sudo systemctl status whatsapp-inbox-retry +sudo -u whatsapp-inbox npm run job:campaign-retry +``` + +- Cek token pada `.env` (`CAMPAIGN_RETRY_JOB_TOKEN`) dan endpoint: + - `https://app.zappcare.id/api/jobs/campaign-retry?token=` +- Jika lock stuck, jalankan: + - `sudo -u whatsapp-inbox npm run job:campaign-retry` + - restart service: `sudo systemctl restart whatsapp-inbox-retry` + +### 18.4 Webhook tidak terima event + +```bash +curl -i https://app.zappcare.id/api/webhooks/whatsapp +``` + +- Pastikan URL di Meta adalah: + - `https://app.zappcare.id/api/webhooks/whatsapp` +- Untuk validasi: + - cek `WHATSAPP_WEBHOOK_VERIFY_TOKEN` + - cek `WHATSAPP_WEBHOOK_SECRET` + - cek `Signature` header dari provider sesuai konfigurasi + +### 18.5 Port bentrok / service lain + +```bash +sudo lsof -i :3000 +sudo lsof -i :3001 +sudo lsof -i :3002 +``` + +- Jika app tidak boleh pakai 3000/3001, pastikan `.env` dan service tetap di 3002. +- Jika ada proses yang tidak dikenal, stop service itu atau pindahkan port dengan service systemd yang benar. + +### 18.6 Cek cepat setelah reboot atau deploy + +```bash +sudo systemctl restart whatsapp-inbox +sudo systemctl restart whatsapp-inbox-retry +sudo systemctl status whatsapp-inbox whatsapp-inbox-retry --no-pager +curl -s https://app.zappcare.id/api/health | cat +``` + +Jika masih ada masalah: + +```bash +APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:readiness +APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:smoke +``` diff --git a/alert-policy.md b/alert-policy.md new file mode 100644 index 0000000..68c3e9d --- /dev/null +++ b/alert-policy.md @@ -0,0 +1,60 @@ +# Alert Policy - WhatsApp Inbox + +## Severity Matrix + +- **P0 (Critical)** – Sistem down untuk seluruh user. + - Trigger: + - `GET /api/health` status `down`. + - DB unreachable. + - Tidak bisa mengirim/menarik retry campaign selama >15 menit. + - Response target: + - Acknowledge: 5 menit + - Mitigasi awal: 15 menit + - Owner: Platform Lead + +- **P1 (High)** – Fitur inti terganggu (campaign, webhook, retry). + - Trigger: + - Retry worker status `failed` >= 3 kali berturut. + - `BackgroundJobState.consecutiveFailures` naik terus. + - `campaign-retry-worker` tidak berjalan > 60 menit. + - Failed webhook 1 jam melebihi threshold. + - Response target: + - Acknowledge: 15 menit + - Mitigasi awal: 45 menit + - Owner: Platform + Operations + +- **P2 (Medium)** – Degradasi performa non-blocking. + - Trigger: + - `GET /api/health` `degraded`. + - Channel disconnected > 1 dalam 1 tenant. + - Response target: + - Acknowledge: 60 menit + - Mitigasi awal: 4 jam + - Owner: Platform + +- **P3 (Low)** – Informasi operasional. + - Trigger: + - Kenaikan event minor, warning non-urgent. + - Response target: + - Acknowledge: next business cycle + +## Alert Routing + +- Primary: Slack/Discord webhook (`CAMPAIGN_RETRY_ALERT_WEBHOOK_URL`) untuk event retry failure. +- Secondary: Team channel / chat group. +- Escalation (P0/P1): paging on-call. + +## Tuning + +- Set `CAMPAIGN_RETRY_ALERT_ON_FAILURE=false` jika volume alert terlalu tinggi dan gunakan manual monitoring. +- Tune: + - `WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR` (default 20) + - `RETRY_WORKER_STALE_MINUTES` (default 30) + +## Metrics reviewed in every shift + +- `WebhookEvent` failure rate (1h) +- `BackgroundJobState.consecutiveFailures` +- `Channel.status` `DISCONNECTED` +- Queue depth `CampaignRecipient` by `sendStatus` +- Health endpoint status diff --git a/app/agent/contacts/[contactId]/page.tsx b/app/agent/contacts/[contactId]/page.tsx new file mode 100644 index 0000000..7e977f4 --- /dev/null +++ b/app/agent/contacts/[contactId]/page.tsx @@ -0,0 +1,13 @@ +import { ShellPage } from "@/components/page-templates"; +import { SectionCard } from "@/components/ui"; + +export default function AgentContactDetailPage() { + return ( + +
+ Nama, nomor WhatsApp, tags. + Riwayat ringkas conversation. +
+
+ ); +} diff --git a/app/agent/contacts/page.tsx b/app/agent/contacts/page.tsx new file mode 100644 index 0000000..bfb0d16 --- /dev/null +++ b/app/agent/contacts/page.tsx @@ -0,0 +1,48 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; + +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; + +export default async function AgentContactsPage() { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + if (session.role !== "agent") { + redirect("/unauthorized"); + } + + const contacts = await prisma.contact.findMany({ + where: { tenantId: session.tenantId }, + include: { + contactTags: { + include: { tag: true } + } + }, + orderBy: { lastInteractionAt: "desc" } + }); + + const rows = contacts.map((contact) => [ + contact.fullName, + contact.phoneNumber, + contact.lastInteractionAt ? new Intl.DateTimeFormat("id-ID", { hour: "2-digit", minute: "2-digit" }).format(contact.lastInteractionAt) : "-" + , + + View + + ]); + + return ( + + + + ); +} diff --git a/app/agent/inbox/mentioned/page.tsx b/app/agent/inbox/mentioned/page.tsx new file mode 100644 index 0000000..7609263 --- /dev/null +++ b/app/agent/inbox/mentioned/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; +import { getAgentMentionedConversations } from "@/lib/inbox-ops"; + +export default async function AgentMentionedPage() { + const rows = await getAgentMentionedConversations(); + + return ( + + [ + + {item.contactName} + , + item.mentionedBy, + item.time + ])} + /> + + ); +} diff --git a/app/agent/inbox/page.tsx b/app/agent/inbox/page.tsx new file mode 100644 index 0000000..e545012 --- /dev/null +++ b/app/agent/inbox/page.tsx @@ -0,0 +1,48 @@ +import { InboxPlaceholder } from "@/components/placeholders"; +import { ShellPage } from "@/components/page-templates"; +import { + addConversationNote, + assignConversation, + getInboxWorkspace, + replyToConversation, + setConversationTags, + updateConversationStatus +} from "@/lib/inbox-ops"; + +const allowedFilters = ["all", "open", "pending", "resolved", "unassigned"] as const; + +export default async function AgentInboxPage({ + searchParams +}: { + searchParams: Promise<{ conversationId?: string; filter?: string }>; +}) { + const params = await searchParams; + const filter = + params?.filter && allowedFilters.includes(params.filter as (typeof allowedFilters)[number]) + ? (params.filter as (typeof allowedFilters)[number]) + : "all"; + + const data = await getInboxWorkspace({ + scope: "agent", + conversationId: params?.conversationId, + filter + }); + + return ( + + + + ); +} diff --git a/app/agent/inbox/resolved/page.tsx b/app/agent/inbox/resolved/page.tsx new file mode 100644 index 0000000..70c2b8b --- /dev/null +++ b/app/agent/inbox/resolved/page.tsx @@ -0,0 +1,17 @@ +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; +import { getAgentResolvedHistory } from "@/lib/inbox-ops"; + +export default async function AgentResolvedPage() { + const rows = await getAgentResolvedHistory(); + + return ( + + [item.contactName, item.resolvedAt, item.lastAction])} + /> + + ); +} diff --git a/app/agent/inbox/unassigned/page.tsx b/app/agent/inbox/unassigned/page.tsx new file mode 100644 index 0000000..b141e74 --- /dev/null +++ b/app/agent/inbox/unassigned/page.tsx @@ -0,0 +1,31 @@ +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; +import { assignConversation, getInboxWorkspace } from "@/lib/inbox-ops"; + +export default async function AgentUnassignedPage() { + const data = await getInboxWorkspace({ scope: "agent", filter: "unassigned" }); + + return ( + + [ + <> +

{item.name}

+

{item.phone}

+ , + item.snippet, + item.time, +
+ + + +
+ ])} + /> +
+ ); +} diff --git a/app/agent/page.tsx b/app/agent/page.tsx new file mode 100644 index 0000000..44e3eef --- /dev/null +++ b/app/agent/page.tsx @@ -0,0 +1,18 @@ +import { DashboardPlaceholder } from "@/components/placeholders"; +import { PlaceholderActions, ShellPage } from "@/components/page-templates"; +import { getDashboardData } from "@/lib/platform-data"; + +export default async function AgentDashboardPage() { + const data = await getDashboardData(); + + return ( + } + > + + + ); +} diff --git a/app/agent/performance/page.tsx b/app/agent/performance/page.tsx new file mode 100644 index 0000000..db89fae --- /dev/null +++ b/app/agent/performance/page.tsx @@ -0,0 +1,13 @@ +import { DashboardPlaceholder } from "@/components/placeholders"; +import { ShellPage } from "@/components/page-templates"; +import { getDashboardData } from "@/lib/platform-data"; + +export default async function AgentPerformancePage() { + const data = await getDashboardData(); + + return ( + + + + ); +} diff --git a/app/agent/quick-tools/page.tsx b/app/agent/quick-tools/page.tsx new file mode 100644 index 0000000..b18f06a --- /dev/null +++ b/app/agent/quick-tools/page.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; + +function truncate(value: string, limit: number) { + return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`; +} + +export default async function AgentQuickToolsPage() { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + if (session.role !== "agent") { + redirect("/unauthorized"); + } + + const tenantId = session.tenantId; + const [templateCount, activeTemplates, followUpNotes] = await Promise.all([ + prisma.messageTemplate.count({ where: { tenantId } }), + prisma.messageTemplate.count({ where: { tenantId, approvalStatus: "APPROVED" } }), + prisma.conversationNote.count({ where: { tenantId, userId: session.userId } }) + ]); + + const totalMessages = await prisma.conversationActivity.count({ where: { tenantId, actorUserId: session.userId } }); + + return ( + + + Open templates + + ], + ["Template Picker", "Pilih template yang sudah disetujui.", `${activeTemplates}/${templateCount} approved`, Open templates], + ["Follow-up Notes", "Catatan follow-up dari conversation sendiri.", String(followUpNotes), Open inbox] + ]} + /> + + ); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..6d55020 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; + +type ComponentHealth = { + status: "ok" | "degraded" | "down"; + message: string; + meta?: unknown; +}; + +function normalizePositiveNumber(value: string | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function maybeExposeDetails(req: NextRequest) { + const expected = process.env.HEALTHCHECK_TOKEN?.trim(); + if (!expected) { + return false; + } + + const fromHeader = req.headers.get("authorization")?.trim() || req.headers.get("x-health-token")?.trim(); + const fromQuery = new URL(req.url).searchParams.get("token")?.trim(); + const token = fromHeader || fromQuery; + if (!token) { + return false; + } + + return token === expected || token === `Bearer ${expected}`; +} + +function isUp(components: ComponentHealth[]) { + return components.every((item) => item.status === "ok"); +} + +export async function GET(req: NextRequest) { + const checks: Record = {}; + + try { + await prisma.$queryRaw`SELECT 1`; + checks.database = { status: "ok", message: "connected" }; + } catch (error) { + checks.database = { + status: "down", + message: error instanceof Error ? error.message : "Database query failed" + }; + } + + let retries: ComponentHealth = { status: "ok", message: "campaign retry worker state unavailable" }; + let webhook: ComponentHealth = { status: "ok", message: "webhook events healthy" }; + if (checks.database.status === "ok") { + const failureThreshold = normalizePositiveNumber(process.env.WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR, 10); + const staleThresholdMinutes = normalizePositiveNumber(process.env.RETRY_WORKER_STALE_MINUTES, 30); + + const [retryState, webhookFailureCount, disconnectedChannels] = await Promise.all([ + prisma.backgroundJobState.findUnique({ + where: { jobName: "campaign-retry-worker" }, + select: { + lockedUntil: true, + lastRunCompletedAt: true, + lastRunStatus: true, + lastError: true, + consecutiveFailures: true + } + }), + prisma.webhookEvent.count({ + where: { + processStatus: "failed", + createdAt: { + gte: new Date(Date.now() - 60 * 60 * 1000) + } + } + }), + prisma.channel.count({ where: { status: "DISCONNECTED" } }) + ]); + + if (!retryState) { + retries = { + status: "degraded", + message: "retry worker state not initialized" + }; + } else { + const staleSince = new Date(Date.now() - staleThresholdMinutes * 60 * 1000); + const isStaleLastRun = retryState.lastRunCompletedAt && retryState.lastRunCompletedAt < staleSince; + const shouldBeDown = retryState.lastRunStatus === "failed" && (retryState.consecutiveFailures ?? 0) >= 3; + + if (shouldBeDown) { + retries = { + status: "down", + message: "retry worker in repeated failure state", + meta: { + status: retryState.lastRunStatus, + consecutiveFailures: retryState.consecutiveFailures + } + }; + } else if (isStaleLastRun) { + retries = { + status: "degraded", + message: "retry worker hasn't completed a run recently", + meta: { + lastRunCompletedAt: retryState.lastRunCompletedAt?.toISOString() ?? null, + staleMinutes: staleThresholdMinutes + } + }; + } else { + retries = { + status: "ok", + message: `retry worker status: ${retryState.lastRunStatus ?? "unknown"}`, + meta: { + consecutiveFailures: retryState.consecutiveFailures ?? 0 + } + }; + } + } + + if (webhookFailureCount > failureThreshold) { + webhook = { + status: "degraded", + message: `high webhook failure volume: ${webhookFailureCount} in 60m`, + meta: { count: webhookFailureCount, threshold: failureThreshold } + }; + } else if (disconnectedChannels > 0) { + webhook = { + status: "degraded", + message: `disconnected channels: ${disconnectedChannels}`, + meta: { disconnectedChannels } + }; + } + } else { + retries = { + status: "down", + message: "skipped due to database not available" + }; + webhook = { + status: "down", + message: "skipped due to database not available" + }; + } + + checks.retries = retries; + checks.webhook = webhook; + + const components = Object.entries(checks); + const overall: "ok" | "degraded" | "down" = isUp([checks.database, checks.retries, checks.webhook]) ? "ok" : checks.database.status === "down" ? "down" : "degraded"; + const exposeDetails = maybeExposeDetails(req); + const payload = { + ok: overall !== "down", + status: overall, + components: exposeDetails + ? checks + : Object.fromEntries(components.map(([name, item]) => [name, { status: item.status, message: item.message }])), + timestamp: new Date().toISOString() + }; + + return NextResponse.json(payload, { status: overall === "down" ? 503 : 200 }); +} diff --git a/app/api/jobs/campaign-retry/route.ts b/app/api/jobs/campaign-retry/route.ts new file mode 100644 index 0000000..68841c2 --- /dev/null +++ b/app/api/jobs/campaign-retry/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getRequestAuditContext } from "@/lib/audit"; +import { getCampaignRetryState, runCampaignRetryBatch } from "@/lib/campaign-dispatch-service"; +import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; + +type JobPayload = { + tenantId?: string; + campaignId?: string; + recipientBatchSize?: number; + maxCampaigns?: number; +}; + +function isAuthorized(req: NextRequest) { + const expected = process.env.CAMPAIGN_RETRY_JOB_TOKEN?.trim(); + if (!expected) { + return process.env.NODE_ENV !== "production"; + } + + const tokenFromHeader = req.headers.get("authorization")?.trim() || req.headers.get("x-cron-token")?.trim(); + const tokenFromQuery = new URL(req.url).searchParams.get("token")?.trim(); + const token = tokenFromHeader || tokenFromQuery; + if (!token) { + return false; + } + + return token === expected || token === `Bearer ${expected}`; +} + +function resolveNumber(raw: string | undefined, fallback: number) { + const value = Number(raw?.trim()); + if (!Number.isInteger(value) || value <= 0) { + return fallback; + } + + return value; +} + +export async function GET(req: NextRequest) { + const { ipAddress: requestIpAddress } = await getRequestAuditContext(); + const retryRate = consumeRateLimit(requestIpAddress || "unknown", { + scope: "campaign_retry_job_get", + limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET, 60), + windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000) + }); + + if (!retryRate.allowed) { + return NextResponse.json( + { ok: false, error: "Too many requests. Please retry later." }, + { + status: 429, + headers: getRateLimitHeaders(retryRate) + } + ); + } + + if (!isAuthorized(req)) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + const state = await getCampaignRetryState(); + const now = new Date(); + const lockedUntil = state?.lockedUntil ? new Date(state.lockedUntil) : null; + const health = { + isLocked: Boolean(lockedUntil && lockedUntil > now), + isStaleLock: Boolean(lockedUntil && lockedUntil <= now), + lastRunStartedAt: state?.lastRunStartedAt ?? null, + lastRunCompletedAt: state?.lastRunCompletedAt ?? null, + lastRunStatus: state?.lastRunStatus ?? null + }; + + return NextResponse.json({ ok: true, state, health }); +} + +export async function POST(req: NextRequest) { + const { ipAddress: requestIpAddress } = await getRequestAuditContext(); + const retryRate = consumeRateLimit(requestIpAddress || "unknown", { + scope: "campaign_retry_job_post", + limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST, 20), + windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000) + }); + + if (!retryRate.allowed) { + return NextResponse.json( + { ok: false, error: "Too many requests. Please retry later." }, + { + status: 429, + headers: getRateLimitHeaders(retryRate) + } + ); + } + + if (!isAuthorized(req)) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + let payload: unknown = {}; + try { + payload = (await req.json()) as unknown; + } catch { + payload = {}; + } + + const safePayload = payload as JobPayload; + const tenantId = safePayload?.tenantId?.trim?.() || undefined; + const campaignId = safePayload?.campaignId?.trim?.() || undefined; + + const recipientBatchSize = Number.isInteger(safePayload?.recipientBatchSize) + ? safePayload?.recipientBatchSize + : undefined; + const maxCampaigns = Number.isInteger(safePayload?.maxCampaigns) + ? safePayload?.maxCampaigns + : undefined; + + const { ipAddress, userAgent } = await getRequestAuditContext(); + + try { + const result = await runCampaignRetryBatch({ + campaignId, + tenantId, + actorIpAddress: ipAddress, + actorUserAgent: userAgent, + actorUserId: null, + recipientBatchSize, + maxCampaigns + }); + + return NextResponse.json({ ok: true, ...result }); + } catch (error) { + const message = error instanceof Error ? error.message : "Campaign retry job failed"; + return NextResponse.json({ ok: false, error: message }, { status: 500 }); + } +} diff --git a/app/api/webhooks/whatsapp/route.ts b/app/api/webhooks/whatsapp/route.ts new file mode 100644 index 0000000..d966be6 --- /dev/null +++ b/app/api/webhooks/whatsapp/route.ts @@ -0,0 +1,805 @@ +import crypto from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; + +import { + ConversationStatus, + DeliveryStatus, + MessageDirection, + MessageType, + OptInStatus +} from "@prisma/client"; + +import { prisma } from "@/lib/prisma"; +import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; +import { recalculateCampaignTotals } from "@/lib/campaign-utils"; +import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; + +type JsonRecord = Record; + +type NormalizedEvent = { + eventType: string; + tenantId: string; + channelId?: string; + channelPhoneNumberId?: string; + providerEventId?: string | null; + payload: JsonRecord; + rawDirection: "inbound" | "status" | "other"; + inbound?: { + from: string; + body?: string; + contactName?: string; + messageId?: string | null; + }; + status?: { + messageId?: string | null; + deliveryStatus: "sent" | "delivered" | "read" | "failed" | string; + failureReason?: string; + }; +}; + +type WebhookProcessStatus = "processed" | "failed" | "skipped"; + +function getString(value: unknown) { + if (typeof value === "string") { + return value.trim(); + } + + return ""; +} + +function normalizePhone(value: string) { + return value.replace(/\D/g, ""); +} + +function resolveNumber(raw: string | undefined, fallback: number) { + const parsed = Number(raw?.trim()); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function getWebhookIp(req: NextRequest) { + const forwarded = req.headers.get("x-forwarded-for"); + return (forwarded ? forwarded.split(",")[0]?.trim() : null) + || req.headers.get("x-real-ip") + || "unknown"; +} + +function buildWebhookEventHash(event: NormalizedEvent, resolvedChannelId: string) { + const inboundBody = event.inbound?.body?.trim(); + const statusDelivery = event.status?.deliveryStatus?.trim(); + const peerPhone = + event.inbound?.from || + event.channelPhoneNumberId || + event.providerEventId || + null; + const payload = { + tenantId: event.tenantId, + eventType: event.eventType, + channelId: resolvedChannelId, + providerEventId: event.providerEventId?.trim() || null, + direction: event.rawDirection, + messageId: event.status?.messageId || event.inbound?.messageId || null, + peerPhone, + bodyHash: inboundBody ? crypto.createHash("sha256").update(inboundBody).digest("hex") : null, + deliveryStatus: statusDelivery || null, + failureReason: event.status?.failureReason?.trim() || null + }; + + return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"); +} + +async function isDuplicateWebhookEvent(tenantId: string, channelId: string, eventHash: string) { + const existing = await prisma.webhookEvent.findFirst({ + where: { + tenantId, + channelId, + eventHash, + processStatus: { + in: ["processed", "skipped"] + } + }, + select: { id: true } + }); + + return Boolean(existing); +} + +async function writeWebhookEvent(params: { + tenantId: string; + channelId: string | null; + event: NormalizedEvent; + processStatus: WebhookProcessStatus; + eventHash: string; + failedReason?: string; +}) { + const now = new Date(); + await prisma.webhookEvent.create({ + data: { + tenantId: params.tenantId, + channelId: params.channelId ?? null, + eventType: params.event.eventType, + providerEventId: params.event.providerEventId, + payloadJson: JSON.stringify(params.event.payload), + processStatus: params.processStatus, + failedReason: params.failedReason, + eventHash: params.eventHash, + processedAt: params.processStatus !== "failed" ? now : null + } + }); +} + +function getStatusDelivery(status: string): DeliveryStatus { + const normalized = status.toLowerCase(); + if (normalized === "sent") { + return DeliveryStatus.SENT; + } + + if (normalized === "delivered") { + return DeliveryStatus.DELIVERED; + } + + if (normalized === "read") { + return DeliveryStatus.READ; + } + + if (normalized === "accepted" || normalized === "accepted_by_sms" || normalized === "pending") { + return DeliveryStatus.SENT; + } + + if (normalized === "undelivered") { + return DeliveryStatus.FAILED; + } + + if (normalized === "failed") { + return DeliveryStatus.FAILED; + } + + return DeliveryStatus.QUEUED; +} + +function shouldAdvanceDeliveryStatus(currentStatus: DeliveryStatus, nextStatus: DeliveryStatus) { + const score: Record = { + [DeliveryStatus.QUEUED]: 1, + [DeliveryStatus.SENT]: 2, + [DeliveryStatus.DELIVERED]: 3, + [DeliveryStatus.READ]: 4, + [DeliveryStatus.FAILED]: 0 + }; + + if (nextStatus === DeliveryStatus.READ) { + return DeliveryStatus.READ; + } + + if (nextStatus === DeliveryStatus.DELIVERED && currentStatus === DeliveryStatus.READ) { + return DeliveryStatus.READ; + } + + if (nextStatus === DeliveryStatus.FAILED && (currentStatus === DeliveryStatus.DELIVERED || currentStatus === DeliveryStatus.READ)) { + return currentStatus; + } + + if (nextStatus === DeliveryStatus.FAILED && (currentStatus === DeliveryStatus.FAILED || currentStatus === DeliveryStatus.SENT || currentStatus === DeliveryStatus.QUEUED)) { + return nextStatus; + } + + if (currentStatus === DeliveryStatus.FAILED && nextStatus !== DeliveryStatus.DELIVERED) { + return currentStatus; + } + + if (score[nextStatus] > score[currentStatus]) { + return nextStatus; + } + + return currentStatus; +} + +function verifyMetaSignature(rawBody: string, signatureHeader: string | null) { + const secret = process.env.WHATSAPP_WEBHOOK_SECRET?.trim(); + if (!secret) { + if (process.env.NODE_ENV === "production") { + return false; + } + return true; + } + + if (!signatureHeader) { + return false; + } + + const split = signatureHeader.split("="); + if (split.length !== 2 || split[0] !== "sha256") { + return false; + } + + const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + const provided = split[1]; + if (provided.length !== expected.length) { + return false; + } + + const expectedBytes = Buffer.from(expected, "hex"); + const providedBytes = Buffer.from(provided, "hex"); + return crypto.timingSafeEqual(expectedBytes, providedBytes); +} + +function parseMetaPayload(payload: JsonRecord) { + const tenantId = getString(payload.tenantId) || getString(payload.tenant_id); + const out: NormalizedEvent[] = []; + const entry = payload.entry; + + if (!Array.isArray(entry)) { + return out; + } + + for (const entryItem of entry) { + const rawChanges = (entryItem as JsonRecord).changes; + const changes = Array.isArray(rawChanges) ? (rawChanges as unknown[]) : []; + + for (const rawChange of changes) { + const change = rawChange as JsonRecord; + const value = change.value as JsonRecord | undefined; + if (!value || typeof value !== "object") { + continue; + } + + const metadata = value.metadata as JsonRecord | undefined; + const phoneNumberId = getString(metadata?.phone_number_id || metadata?.phoneNumberId); + const messages = Array.isArray(value.messages) ? value.messages : []; + const statuses = Array.isArray(value.statuses) ? value.statuses : []; + + for (const rawMessage of messages) { + const message = rawMessage as JsonRecord; + const messageId = getString(message.id); + const from = normalizePhone(getString(message.from)); + const text = (message.text as JsonRecord | undefined)?.body; + const body = getString(text); + + if (!from || !tenantId) { + continue; + } + + const contacts = Array.isArray(value.contacts) ? value.contacts : []; + const matchedContact = contacts.find((item) => getString((item as JsonRecord).wa_id) === from) as JsonRecord | undefined; + const rawProfile = typeof matchedContact?.profile === "object" ? (matchedContact.profile as JsonRecord | null) : null; + const contactName = getString(rawProfile?.name) || from; + + out.push({ + eventType: "message.inbound", + tenantId, + channelPhoneNumberId: phoneNumberId || undefined, + providerEventId: messageId || undefined, + payload, + rawDirection: "inbound", + inbound: { + from, + body, + contactName, + messageId: messageId || null + } + }); + } + + for (const rawStatus of statuses) { + const statusValue = rawStatus as JsonRecord; + const status = getString(statusValue.status); + const messageId = getString(statusValue.id); + const to = normalizePhone(getString(statusValue.recipient_id || statusValue.to)); + + if (!tenantId || !messageId) { + continue; + } + + out.push({ + eventType: "message.status", + tenantId, + channelPhoneNumberId: phoneNumberId || to || undefined, + providerEventId: messageId, + payload, + rawDirection: "status", + status: { + messageId, + deliveryStatus: status, + failureReason: getString((statusValue.errors as unknown as JsonRecord[])?.[0]?.title) + } + }); + } + } + } + + return out; +} + +function parseLegacyPayload(payload: JsonRecord) { + const out: NormalizedEvent[] = []; + const tenantId = getString(payload.tenantId || payload.tenant_id); + const eventType = getString(payload.eventType || payload.type || payload.event_type || payload.event); + + if (!tenantId || !eventType) { + return out; + } + + if (eventType.includes("status")) { + out.push({ + eventType, + tenantId, + channelId: getString(payload.channelId || payload.channel_id) || undefined, + channelPhoneNumberId: getString(payload.channelPhoneNumberId || payload.phoneNumberId || payload.phone_number_id) || undefined, + providerEventId: getString(payload.providerEventId || payload.eventId || payload.id), + payload, + rawDirection: "status", + status: { + messageId: getString(payload.messageId || payload.message_id), + deliveryStatus: getString(payload.status || "failed"), + failureReason: getString(payload.failureReason || payload.failedReason) + } + }); + return out; + } + + if (eventType.includes("inbound") || eventType.includes("message")) { + out.push({ + eventType, + tenantId, + channelId: getString(payload.channelId || payload.channel_id) || undefined, + channelPhoneNumberId: getString(payload.channelPhoneNumberId || payload.phoneNumberId || payload.phone_number_id) || undefined, + providerEventId: getString(payload.providerMessageId || payload.messageId || payload.id), + payload, + rawDirection: "inbound", + inbound: { + from: normalizePhone(getString(payload.from || payload.phone || payload.recipient)), + body: getString(payload.body || (payload.message as JsonRecord)?.body || payload.text), + contactName: getString(payload.contactName || payload.name), + messageId: getString(payload.providerMessageId || payload.messageId || payload.id) + } + }); + } + + return out; +} + +function mapEventDirection(event: NormalizedEvent) { + if (event.rawDirection === "other") { + return "other"; + } + + if (event.rawDirection === "status" || event.rawDirection === "inbound") { + return event.rawDirection; + } + + return "other"; +} + +async function resolveChannelId(tenantId: string, channelId?: string, phoneNumberId?: string) { + if (channelId) { + const channel = await prisma.channel.findFirst({ where: { id: channelId, tenantId } }); + return channel?.id ?? null; + } + + if (phoneNumberId) { + const channel = await prisma.channel.findFirst({ where: { phoneNumberId, tenantId } }); + if (channel) { + return channel.id; + } + } + + return null; +} + +export async function GET(req: NextRequest) { + const rate = consumeRateLimit(getWebhookIp(req), { + scope: "whatsapp_webhook_get", + limit: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_GET, 60), + windowMs: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS, 60 * 1000) + }); + + if (!rate.allowed) { + return NextResponse.json( + { ok: false, error: "Too many webhook verification requests" }, + { + status: 429, + headers: getRateLimitHeaders(rate) + } + ); + } + + const verifyToken = process.env.WHATSAPP_WEBHOOK_VERIFY_TOKEN?.trim() || ""; + const mode = req.nextUrl.searchParams.get("hub.mode"); + const token = req.nextUrl.searchParams.get("hub.verify_token"); + const challenge = req.nextUrl.searchParams.get("hub.challenge"); + + if (mode === "subscribe" && token === verifyToken && challenge) { + return new NextResponse(challenge, { status: 200 }); + } + + return NextResponse.json({ ok: false, error: "Invalid verification request" }, { status: 403 }); +} + +export async function POST(req: NextRequest) { + const rate = consumeRateLimit(getWebhookIp(req), { + scope: "whatsapp_webhook_post", + limit: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_POST, 120), + windowMs: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS, 60 * 1000) + }); + + if (!rate.allowed) { + return NextResponse.json( + { ok: false, error: "Too many webhook events" }, + { + status: 429, + headers: getRateLimitHeaders(rate) + } + ); + } + + const raw = await req.text(); + + if (!verifyMetaSignature(raw, req.headers.get("x-hub-signature-256"))) { + return NextResponse.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 }); + } + + let payload: unknown; + try { + payload = JSON.parse(raw); + } catch { + return NextResponse.json({ ok: false, error: "Invalid JSON payload" }, { status: 400 }); + } + + if (!payload || typeof payload !== "object") { + return NextResponse.json({ ok: false, error: "Payload must be a JSON object" }, { status: 400 }); + } + + const payloadObj = payload as JsonRecord; + const parsedEvents = [...parseMetaPayload(payloadObj), ...parseLegacyPayload(payloadObj)]; + if (parsedEvents.length === 0) { + return NextResponse.json({ ok: true, processed: 0, skipped: 0 }); + } + + let processed = 0; + let failed = 0; + let skipped = 0; + + for (const event of parsedEvents) { + const direction = mapEventDirection(event); + const now = new Date(); + + const resolvedChannelId = await resolveChannelId(event.tenantId, event.channelId, event.channelPhoneNumberId); + if (!resolvedChannelId) { + const eventHash = buildWebhookEventHash(event, event.channelPhoneNumberId || event.channelId || "unresolved"); + await writeWebhookEvent({ + tenantId: event.tenantId, + channelId: null, + event, + eventHash, + processStatus: "failed", + failedReason: "Channel not found" + }); + failed += 1; + continue; + } + + const eventHash = buildWebhookEventHash(event, resolvedChannelId); + if (await isDuplicateWebhookEvent(event.tenantId, resolvedChannelId, eventHash)) { + await writeWebhookEvent({ + tenantId: event.tenantId, + channelId: resolvedChannelId, + event, + eventHash, + processStatus: "skipped" + }); + skipped += 1; + continue; + } + + if (direction === "inbound") { + const inbound = event.inbound; + if (!inbound) { + failed += 1; + await writeWebhookEvent({ + tenantId: event.tenantId, + channelId: resolvedChannelId, + event, + eventHash, + processStatus: "failed", + failedReason: "Invalid inbound payload" + }); + continue; + } + + const fromPhone = inbound.from; + if (!fromPhone) { + failed += 1; + await writeWebhookEvent({ + tenantId: event.tenantId, + channelId: resolvedChannelId, + event, + eventHash, + processStatus: "failed", + failedReason: "Missing sender phone number" + }); + continue; + } + + const contact = await prisma.contact.upsert({ + where: { + tenantId_phoneNumber: { + tenantId: event.tenantId, + phoneNumber: fromPhone + } + }, + create: { + tenantId: event.tenantId, + channelId: resolvedChannelId, + fullName: inbound.contactName || fromPhone, + phoneNumber: fromPhone, + optInStatus: OptInStatus.OPTED_IN + }, + update: { + channelId: resolvedChannelId, + fullName: inbound.contactName || fromPhone + } + }); + + let conversation = await prisma.conversation.findFirst({ + where: { + tenantId: event.tenantId, + channelId: resolvedChannelId, + contactId: contact.id + }, + orderBy: { lastMessageAt: "desc" } + }); + + if (!conversation) { + conversation = await prisma.conversation.create({ + data: { + tenantId: event.tenantId, + channelId: resolvedChannelId, + contactId: contact.id, + subject: inbound.body?.slice(0, 80) ?? "WhatsApp inbound", + firstMessageAt: now, + lastMessageAt: now, + lastInboundAt: now, + status: ConversationStatus.OPEN + } + }); + } + + const existingInbound = inbound.messageId + ? await prisma.message.findUnique({ + where: { providerMessageId: inbound.messageId } + }) + : null; + + if (!inbound.messageId || !existingInbound) { + await prisma.message.create({ + data: { + tenantId: event.tenantId, + conversationId: conversation.id, + channelId: resolvedChannelId, + contactId: contact.id, + direction: MessageDirection.INBOUND, + type: MessageType.TEXT, + providerMessageId: inbound.messageId, + contentText: inbound.body, + sentAt: now, + sentByUserId: null + } + }); + } + + await prisma.conversation.update({ + where: { id: conversation.id }, + data: { + lastMessageAt: now, + lastInboundAt: now, + status: ConversationStatus.OPEN + } + }); + + await prisma.contact.update({ + where: { id: contact.id }, + data: { lastInteractionAt: now } + }); + + await prisma.conversationActivity.create({ + data: { + tenantId: event.tenantId, + conversationId: conversation.id, + actorUserId: null, + activityType: "MESSAGE_RECEIVED", + metadataJson: JSON.stringify({ + provider: "webhook", + messageId: inbound.messageId, + body: inbound.body?.slice(0, 120) + }) + } + }); + + await writeWebhookEvent({ + tenantId: event.tenantId, + channelId: resolvedChannelId, + event, + eventHash, + processStatus: "processed" + }); + + await prisma.channel.update({ + where: { id: resolvedChannelId }, + data: { webhookStatus: "healthy", lastSyncAt: now } + }); + + processed += 1; + continue; + } + + if (direction === "status") { + const { ipAddress, userAgent } = await getRequestAuditContext(); + const messageId = event.status?.messageId; + if (!messageId) { + failed += 1; + await writeWebhookEvent({ + tenantId: event.tenantId, + channelId: resolvedChannelId, + event, + eventHash, + processStatus: "failed", + failedReason: "Status event missing messageId" + }); + continue; + } + + const targetMessage = await prisma.message.findFirst({ + where: { + tenantId: event.tenantId, + providerMessageId: messageId + }, + include: { conversation: true } + }); + + const campaignRecipient = await prisma.campaignRecipient.findFirst({ + where: { + tenantId: event.tenantId, + providerMessageId: messageId + } + }); + + if (!targetMessage && !campaignRecipient) { + failed += 1; + await writeWebhookEvent({ + tenantId: event.tenantId, + channelId: resolvedChannelId, + event, + eventHash, + processStatus: "failed", + failedReason: "Message not found by providerMessageId" + }); + await writeAuditTrail({ + tenantId: event.tenantId, + actorUserId: null, + entityType: "campaign_recipient", + entityId: messageId, + action: "campaign_delivery_sync_not_found", + metadata: { + providerMessageId: messageId, + providerStatus: event.status?.deliveryStatus, + eventType: event.eventType + }, + ipAddress, + userAgent + }).catch(() => null); + continue; + } + + const mapped = getStatusDelivery(event.status?.deliveryStatus || "queued"); + const resolvedTargetStatus = mapped; + const targetMessageStatus = targetMessage ? shouldAdvanceDeliveryStatus(targetMessage.deliveryStatus, resolvedTargetStatus) : resolvedTargetStatus; + const campaignRecipientStatus = campaignRecipient + ? shouldAdvanceDeliveryStatus(campaignRecipient.sendStatus, resolvedTargetStatus) + : resolvedTargetStatus; + + const nowDelivery = campaignRecipientStatus === DeliveryStatus.DELIVERED || campaignRecipientStatus === DeliveryStatus.READ + ? now + : targetMessageStatus === DeliveryStatus.DELIVERED || targetMessageStatus === DeliveryStatus.READ + ? now + : undefined; + const nowRead = campaignRecipientStatus === DeliveryStatus.READ || targetMessageStatus === DeliveryStatus.READ ? now : undefined; + const txOps = []; + const updateData = { + deliveryStatus: targetMessageStatus, + failedReason: campaignRecipientStatus === DeliveryStatus.FAILED ? event.status?.failureReason : null, + deliveredAt: nowDelivery, + readAt: nowRead + }; + + if (targetMessage) { + txOps.push( + prisma.message.update({ + where: { id: targetMessage.id }, + data: { + ...updateData, + sentAt: targetMessageStatus === DeliveryStatus.SENT && !targetMessage.sentAt ? now : undefined + } + }) + ); + } + + if (targetMessage) { + txOps.push( + prisma.conversationActivity.create({ + data: { + tenantId: event.tenantId, + conversationId: targetMessage.conversationId, + actorUserId: null, + activityType: "DELIVERY_UPDATE", + metadataJson: JSON.stringify({ + providerStatus: mapped, + providerEventId: event.providerEventId, + messageId: targetMessage.id + }) + } + }) + ); + } + + if (campaignRecipient) { + txOps.push( + prisma.campaignRecipient.update({ + where: { id: campaignRecipient.id }, + data: { + sendStatus: campaignRecipientStatus, + failureReason: campaignRecipientStatus === DeliveryStatus.FAILED + ? event.status?.failureReason ?? campaignRecipient?.failureReason ?? null + : campaignRecipient?.failureReason ?? null, + deliveredAt: nowDelivery, + readAt: nowRead, + sentAt: campaignRecipientStatus === DeliveryStatus.SENT && !campaignRecipient.sentAt ? now : campaignRecipient.sentAt, + nextRetryAt: null + } + }) + ); + } + + txOps.push( + writeWebhookEvent({ + tenantId: event.tenantId, + channelId: resolvedChannelId, + event, + eventHash, + processStatus: "processed" + }) + ); + + await Promise.all(txOps); + + if (campaignRecipient) { + await recalculateCampaignTotals(campaignRecipient.campaignId); + } + + await writeAuditTrail({ + tenantId: event.tenantId, + actorUserId: null, + entityType: campaignRecipient ? "campaign_recipient" : "message", + entityId: campaignRecipient?.id || targetMessage?.id || messageId, + action: "message_delivery_status_synced", + metadata: { + providerStatus: resolvedTargetStatus, + appliedStatus: campaignRecipient ? campaignRecipientStatus : targetMessageStatus, + providerMessageId: messageId + }, + ipAddress, + userAgent + }).catch(() => null); + + processed += 1; + } + } + + return NextResponse.json({ + ok: true, + processed, + failed, + skipped + }); +} diff --git a/app/audit-log/page.tsx b/app/audit-log/page.tsx new file mode 100644 index 0000000..dfc5c19 --- /dev/null +++ b/app/audit-log/page.tsx @@ -0,0 +1,45 @@ +import { redirect } from "next/navigation"; + +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; + +function toTime(value: Date) { + return new Intl.DateTimeFormat("id-ID", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }).format(value); +} + +export default async function TenantAuditLogPage() { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const audits = await prisma.auditLog.findMany({ + where: { tenantId: session.tenantId }, + include: { actorUser: true }, + orderBy: { createdAt: "desc" } + }); + + return ( + + [ + toTime(audit.createdAt), + audit.actorUser?.fullName ?? "System", + audit.entityType, + audit.action, + audit.entityId + ])} + /> + + ); +} diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts new file mode 100644 index 0000000..98bb90a --- /dev/null +++ b/app/auth/login/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { SESSION_COOKIE, UserRole, authenticateUser, getDefaultPathForRole, serializeSession } from "@/lib/auth"; +import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; +import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; +import { prisma } from "@/lib/prisma"; + +function getSafePath(value: string | null) { + if (!value) { + return null; + } + + if (!value.startsWith("/")) { + return null; + } + + return value; +} + +function resolveNumber(raw: string | undefined, fallback: number) { + const value = Number(raw?.trim()); + if (!Number.isInteger(value) || value <= 0) { + return fallback; + } + + return value; +} + +export async function POST(request: NextRequest) { + const { ipAddress, userAgent } = await getRequestAuditContext(); + const retryControl = consumeRateLimit(ipAddress || "unknown", { + scope: "auth_login", + limit: resolveNumber(process.env.LOGIN_RATE_LIMIT_ATTEMPTS, 10), + windowMs: resolveNumber(process.env.LOGIN_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000) + }); + + if (!retryControl.allowed) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("error", "rate_limited"); + const response = NextResponse.redirect(loginUrl); + const headers = getRateLimitHeaders(retryControl); + Object.entries(headers).forEach(([headerName, headerValue]) => { + response.headers.set(headerName, headerValue); + }); + return response; + } + + const form = await request.formData(); + const rawEmail = form.get("email"); + const rawPassword = form.get("password"); + const rawNext = form.get("next"); + + const next = getSafePath(typeof rawNext === "string" ? rawNext : null); + const email = typeof rawEmail === "string" ? rawEmail.trim() : ""; + const password = typeof rawPassword === "string" ? rawPassword : ""; + + if (!email || !password) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("error", "credentials_required"); + if (next) { + loginUrl.searchParams.set("next", next); + } + + return NextResponse.redirect(loginUrl); + } + + const session = await authenticateUser(email, password); + if (!session) { + const attemptedUser = await prisma.user.findUnique({ + where: { email }, + select: { id: true, tenantId: true, status: true } + }); + + if (attemptedUser) { + await writeAuditTrail({ + tenantId: attemptedUser.tenantId, + actorUserId: attemptedUser.id, + entityType: "user", + entityId: attemptedUser.id, + action: "user_login_failed", + metadata: { + email, + status: attemptedUser.status, + source: "web" + }, + ipAddress, + userAgent + }); + } + + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("error", "invalid_credentials"); + if (next) { + loginUrl.searchParams.set("next", next); + } + + return NextResponse.redirect(loginUrl); + } + + await prisma.user.update({ + where: { id: session.userId }, + data: { lastLoginAt: new Date() } + }); + + await writeAuditTrail({ + tenantId: session.tenantId, + actorUserId: session.userId, + entityType: "user", + entityId: session.userId, + action: "user_login", + metadata: { + email + }, + ipAddress, + userAgent + }); + + const destination = next ?? getDefaultPathForRole(session.role as UserRole); + const response = NextResponse.redirect(new URL(destination, request.url)); + response.cookies.set(SESSION_COOKIE, await serializeSession(session), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: Math.max(1, Math.floor(session.expiresAt - Date.now() / 1000)) + }); + + return response; +} diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts new file mode 100644 index 0000000..410d87f --- /dev/null +++ b/app/auth/logout/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; +import { getSession, SESSION_COOKIE } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + const session = await getSession(); + const { ipAddress, userAgent } = await getRequestAuditContext(); + + if (session) { + await writeAuditTrail({ + tenantId: session.tenantId, + actorUserId: session.userId, + entityType: "user", + entityId: session.userId, + action: "user_logout", + metadata: { email: session.email }, + ipAddress, + userAgent + }); + } + + const response = NextResponse.redirect(new URL("/login", request.url)); + response.cookies.delete(SESSION_COOKIE); + return response; +} diff --git a/app/billing/history/page.tsx b/app/billing/history/page.tsx new file mode 100644 index 0000000..29dcdfe --- /dev/null +++ b/app/billing/history/page.tsx @@ -0,0 +1,59 @@ +import { redirect } from "next/navigation"; + +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; +import Link from "next/link"; + +function formatDate(value: Date | null) { + if (!value) { + return "-"; + } + + return new Intl.DateTimeFormat("id-ID", { + month: "short", + year: "numeric" + }).format(value); +} + +function formatMoney(value: number) { + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: "IDR", + maximumFractionDigits: 0 + }).format(value); +} + +export default async function BillingHistoryPage() { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const invoices = await prisma.billingInvoice.findMany({ + where: { tenantId: session.tenantId }, + orderBy: { dueDate: "desc" } + }); + + return ( + + [ + + {invoice.invoiceNumber} + , + `${formatDate(invoice.periodStart)} - ${formatDate(invoice.periodEnd)}`, + formatMoney(invoice.totalAmount), + invoice.paymentStatus + ])} + /> + + ); +} diff --git a/app/billing/invoices/[invoiceId]/page.tsx b/app/billing/invoices/[invoiceId]/page.tsx new file mode 100644 index 0000000..a6c3f8d --- /dev/null +++ b/app/billing/invoices/[invoiceId]/page.tsx @@ -0,0 +1,115 @@ +import Link from "next/link"; +import { ShellPage } from "@/components/page-templates"; +import { Badge, SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; + +function formatDate(value: Date | null) { + if (!value) { + return "-"; + } + + return new Intl.DateTimeFormat("id-ID", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }).format(value); +} + +function formatMoney(value: number) { + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: "IDR", + maximumFractionDigits: 0 + }).format(value); +} + +function statusTone(status: string) { + if (status === "PAID") { + return "success"; + } + + if (status === "OVERDUE") { + return "danger"; + } + + return "warning"; +} + +export default async function BillingInvoiceDetailPage({ + params +}: { + params: Promise<{ invoiceId: string }>; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const { invoiceId } = await params; + const invoice = await prisma.billingInvoice.findFirst({ + where: { id: invoiceId, tenantId: session.tenantId }, + include: { + tenant: { select: { name: true, slug: true } }, + plan: { select: { name: true, code: true } } + } + }); + + if (!invoice) { + redirect("/billing/history?error=invoice_not_found"); + } + + return ( + +
+ +
+

+ Invoice: {invoice.invoiceNumber} +

+

+ Tenant:{" "} + + {invoice.tenant.name} ({invoice.tenant.slug}) + +

+

+ Plan: {invoice.plan.name} ({invoice.plan.code}) +

+

+ Period: {formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)} +

+

+ Subtotal: {formatMoney(invoice.subtotal)} | Tax: {formatMoney(invoice.taxAmount)} +

+

+ Total: {formatMoney(invoice.totalAmount)} +

+

+ Status: {invoice.paymentStatus} +

+
+
+ +
+

+ Issued: {formatDate(invoice.createdAt)} +

+

+ Due date: {formatDate(invoice.dueDate)} +

+

+ Paid at: {formatDate(invoice.paidAt)} +

+

+ Updated: {formatDate(invoice.updatedAt)} +

+
+
+
+
+ ); +} diff --git a/app/billing/page.tsx b/app/billing/page.tsx new file mode 100644 index 0000000..2c7dd58 --- /dev/null +++ b/app/billing/page.tsx @@ -0,0 +1,13 @@ +import { ShellPage } from "@/components/page-templates"; +import { DashboardPlaceholder } from "@/components/placeholders"; +import { getDashboardData } from "@/lib/platform-data"; + +export default async function BillingPage() { + const data = await getDashboardData(); + + return ( + + + + ); +} diff --git a/app/campaigns/[campaignId]/page.tsx b/app/campaigns/[campaignId]/page.tsx new file mode 100644 index 0000000..7c919a1 --- /dev/null +++ b/app/campaigns/[campaignId]/page.tsx @@ -0,0 +1,71 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export default async function CampaignDetailPage({ params }: { params: Promise<{ campaignId: string }> }) { + const { campaignId } = await params; + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const campaign = await prisma.broadcastCampaign.findFirst({ + where: { id: campaignId, tenantId: session.tenantId }, + include: { channel: true, template: true, segment: true, recipients: { include: { contact: true } } } + }); + if (!campaign) { + redirect("/campaigns?error=campaign_not_found"); + } + + return ( + +
+ +

+ Nama: {campaign.name} +

+

+ Template: {campaign.template.name} +

+

+ Channel: {campaign.channel.channelName} +

+

+ Audience: {campaign.audienceType} +

+

+ Segment: {campaign.segment ? campaign.segment.name : "-"} +

+
+ +

+ Total recipients: {campaign.totalRecipients} +

+

+ Sent: {campaign.totalSent} +

+

+ Delivered: {campaign.totalDelivered} +

+

+ Failed: {campaign.totalFailed} +

+

+ Read: {campaign.totalRead} +

+ + Lihat recipients + +
+
+
+ ); +} diff --git a/app/campaigns/[campaignId]/recipients/page.tsx b/app/campaigns/[campaignId]/recipients/page.tsx new file mode 100644 index 0000000..a23fc7b --- /dev/null +++ b/app/campaigns/[campaignId]/recipients/page.tsx @@ -0,0 +1,45 @@ +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export default async function CampaignRecipientsPage({ params }: { params: Promise<{ campaignId: string }> }) { + const { campaignId } = await params; + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const campaign = await prisma.broadcastCampaign.findFirst({ + where: { id: campaignId, tenantId: session.tenantId } + }); + if (!campaign) { + redirect("/campaigns?error=campaign_not_found"); + } + + const recipients = await prisma.campaignRecipient.findMany({ + where: { campaignId }, + include: { contact: true }, + orderBy: { createdAt: "asc" } + }); + + return ( + + [ + recipient.contact?.fullName || "-", + recipient.phoneNumber, + recipient.sendStatus, + recipient.sendAttempts, + recipient.nextRetryAt ? new Date(recipient.nextRetryAt).toLocaleString() : "-", + recipient.failureReason || "-", + recipient.sentAt ? recipient.sentAt.toLocaleString() : "-" + ])} + /> + + ); +} diff --git a/app/campaigns/new/page.tsx b/app/campaigns/new/page.tsx new file mode 100644 index 0000000..542a108 --- /dev/null +++ b/app/campaigns/new/page.tsx @@ -0,0 +1,87 @@ +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { Button, SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { createCampaign } from "@/lib/admin-crud"; +import { CampaignAudienceType } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; + +export default async function NewCampaignPage({ + searchParams +}: { + searchParams?: Promise<{ error?: string }>; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const channels = await prisma.channel.findMany({ where: { tenantId: session.tenantId }, orderBy: { channelName: "asc" } }); + const templates = await prisma.messageTemplate.findMany({ where: { tenantId: session.tenantId }, orderBy: { createdAt: "desc" } }); + const segments = await prisma.contactSegment.findMany({ where: { tenantId: session.tenantId }, orderBy: { name: "asc" } }); + + const params = await (searchParams ?? Promise.resolve({ error: undefined })); + const err = params.error; + const errorMessage = + err === "missing_fields" ? "Isi semua kolom wajib." : err === "invalid_channel" ? "Channel tidak valid." : err === "invalid_template" ? "Template tidak valid." : null; + + return ( + + +
+ {errorMessage ?

{errorMessage}

: null} + + + + + + + +
+
+
+ ); +} diff --git a/app/campaigns/page.tsx b/app/campaigns/page.tsx new file mode 100644 index 0000000..c6670bb --- /dev/null +++ b/app/campaigns/page.tsx @@ -0,0 +1,71 @@ +import Link from "next/link"; + +import { PlaceholderActions, ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; +import { dispatchCampaign, deleteCampaign } from "@/lib/admin-crud"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export default async function CampaignsPage({ + searchParams +}: { + searchParams?: Promise<{ error?: string }>; +}) { + const params = await (searchParams ?? Promise.resolve({ error: undefined })); + const session = await getSession(); + const campaigns = session + ? await prisma.broadcastCampaign.findMany({ + where: { tenantId: session.tenantId }, + include: { channel: true }, + orderBy: { createdAt: "desc" } + }) + : []; + const error = params.error; + const infoMessage = + error === "campaign_not_found" ? "Campaign tidak ditemukan." : error === "missing_fields" ? "Lengkapi data campaign." : null; + const campaignErrorMessage = + error === "no_recipients" ? "Campaign tidak punya recipient (audience kosong)." : error === "campaign_not_ready" ? "Campaign tidak bisa dikirim dalam status ini." : null; + + return ( + } + > + {infoMessage ?

{infoMessage}

: null} + [ + campaign.name, + campaign.channel.channelName, + campaign.audienceType, + campaign.status, + campaign.scheduledAt ? new Date(campaign.scheduledAt).toLocaleDateString() : "Not scheduled", +
+ + Detail + + + Recipients + +
+ + +
+
+ + +
+
+ ])} + /> + {campaignErrorMessage ?

{campaignErrorMessage}

: null} +
+ ); +} diff --git a/app/campaigns/review/page.tsx b/app/campaigns/review/page.tsx new file mode 100644 index 0000000..c5780d9 --- /dev/null +++ b/app/campaigns/review/page.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { Button, SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +function formatDate(date: Date | null | undefined) { + if (!date) { + return "-"; + } + + return new Intl.DateTimeFormat("id-ID", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }).format(date); +} + +export default async function CampaignReviewPage({ + searchParams +}: { + searchParams?: Promise<{ campaignId?: string; error?: string }>; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const query = await (searchParams ?? Promise.resolve<{ campaignId?: string; error?: string }>({})); + const campaignId = query.campaignId; + + const campaign = campaignId + ? await prisma.broadcastCampaign.findFirst({ + where: { + id: campaignId, + tenantId: session.tenantId + }, + include: { + template: { select: { name: true, category: true, approvalStatus: true } }, + channel: { select: { channelName: true } } + } + }) + : null; + + if (campaignId && !campaign) { + redirect("/campaigns/review?error=campaign_not_found"); + } + + return ( + +
+ + {campaign ? ( +
+

+ Nama: {campaign.name} +

+

+ Template: {campaign.template.name} ({campaign.template.category}) •{" "} + {campaign.template.approvalStatus} +

+

+ Channel: {campaign.channel.channelName} +

+

+ Audience: {campaign.audienceType} +

+

+ Scheduled: {formatDate(campaign.scheduledAt)} +

+

+ Status: {campaign.status} +

+

+ Recipient estimate: {campaign.totalRecipients} +

+

Estimasi sukses: {(campaign.totalRecipients * 0.82).toFixed(0)} kontak

+
+ ) : ( +

Pilih campaign dari halaman campaign list untuk menampilkan detail review.

+ )} +
+ + {campaign ? ( +
+

Template approval: {campaign.template.approvalStatus}

+

Audience validation: OK

+

Recipient validation: {campaign.totalRecipients > 0 ? "PASS" : "No recipients"}

+

Channel availability: Available

+
+ ) : ( +

Tidak ada pemeriksaan yang berjalan karena campaign belum dipilih.

+ )} +
+
+
+ {campaign ? ( + + ) : ( + + )} + + + Back + +
+
+ ); +} diff --git a/app/contacts/[contactId]/edit/page.tsx b/app/contacts/[contactId]/edit/page.tsx new file mode 100644 index 0000000..b83037f --- /dev/null +++ b/app/contacts/[contactId]/edit/page.tsx @@ -0,0 +1,89 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { Button, SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { updateContact } from "@/lib/admin-crud"; +import { prisma } from "@/lib/prisma"; + +export default async function EditContactPage({ params }: { params: Promise<{ contactId: string }> }) { + const { contactId } = await params; + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const contact = await prisma.contact.findFirst({ + where: { id: contactId, tenantId: session.tenantId }, + include: { contactTags: { include: { tag: true } }, channel: true } + }); + if (!contact) { + redirect("/contacts?error=contact_not_found"); + } + + const channels = await prisma.channel.findMany({ + where: { tenantId: session.tenantId }, + orderBy: { channelName: "asc" } + }); + + const tags = contact.contactTags.map((item) => item.tag.name).join(", "); + + return ( + + +
+ + + + + + + + +
+ + + Cancel + +
+
+
+
+ ); +} diff --git a/app/contacts/[contactId]/page.tsx b/app/contacts/[contactId]/page.tsx new file mode 100644 index 0000000..9eeeaab --- /dev/null +++ b/app/contacts/[contactId]/page.tsx @@ -0,0 +1,73 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export default async function ContactDetailPage({ params }: { params: Promise<{ contactId: string }> }) { + const { contactId } = await params; + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const contact = await prisma.contact.findFirst({ + where: { id: contactId, tenantId: session.tenantId }, + include: { + contactTags: { include: { tag: true } }, + conversations: true, + channel: true + } + }); + + if (!contact) { + redirect("/contacts?error=contact_not_found"); + } + + return ( + Edit contact} + > +
+ +

+ Nama: {contact.fullName} +

+

+ Phone: {contact.phoneNumber} +

+

+ Email: {contact.email || "-"} +

+

+ Channel: {contact.channel?.channelName || "Unset"} +

+

+ Opt-in: {contact.optInStatus} +

+

+ Last interaction: {contact.lastInteractionAt?.toLocaleString() || "-"} +

+

+ + Kembali ke daftar + +

+
+ +

+ Total conversations: {contact.conversations.length} +

+

+ Tags: {contact.contactTags.map((item) => item.tag.name).join(", ") || "-"} +

+
+
+
+ ); +} diff --git a/app/contacts/export/page.tsx b/app/contacts/export/page.tsx new file mode 100644 index 0000000..bc32c59 --- /dev/null +++ b/app/contacts/export/page.tsx @@ -0,0 +1,73 @@ +import { ShellPage } from "@/components/page-templates"; +import { Button, SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; + +export default async function ExportContactsPage() { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + if (session.role === "agent") { + redirect("/unauthorized"); + } + + const [segments, tags, count, lastUpdated] = await Promise.all([ + prisma.contactSegment.findMany({ + where: { tenantId: session.tenantId }, + orderBy: { name: "asc" }, + select: { id: true, name: true, _count: { select: { members: true } } } + }), + prisma.tag.findMany({ where: { tenantId: session.tenantId }, select: { name: true }, orderBy: { name: "asc" } }), + prisma.contact.count({ where: { tenantId: session.tenantId } }), + prisma.contact.findFirst({ + where: { tenantId: session.tenantId }, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true } + }) + ]); + + const fields = ["fullName", "phoneNumber", "email", "countryCode", "optInStatus", "createdAt", "updatedAt"]; + + return ( + + +
+

+ Total kontak: {count} • Last updated: {lastUpdated?.updatedAt ? new Intl.DateTimeFormat("id-ID").format(lastUpdated.updatedAt) : "-"} +

+ + + +

Available tags: {tags.length ? tags.map((tag) => tag.name).join(", ") : "-"}

+
+ +
+
+ {segments.length === 0 ? ( +

Tambahkan segment untuk filtering export yang lebih presisi.

+ ) : null} +
+
+
+
+ ); +} diff --git a/app/contacts/import/page.tsx b/app/contacts/import/page.tsx new file mode 100644 index 0000000..776b5db --- /dev/null +++ b/app/contacts/import/page.tsx @@ -0,0 +1,95 @@ +import { ShellPage } from "@/components/page-templates"; +import { SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; + +function formatDate(value: Date | null | undefined) { + if (!value) { + return "-"; + } + + return new Intl.DateTimeFormat("id-ID", { + day: "2-digit", + month: "short", + year: "numeric" + }).format(value); +} + +export default async function ImportContactsPage() { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + if (session.role === "agent") { + redirect("/unauthorized"); + } + + const [channels, tags, sampleContacts] = await Promise.all([ + prisma.channel.findMany({ + where: { tenantId: session.tenantId }, + select: { id: true, channelName: true, provider: true } + }), + prisma.tag.findMany({ + where: { tenantId: session.tenantId }, + orderBy: { name: "asc" } + }), + prisma.contact.findMany({ + where: { tenantId: session.tenantId }, + orderBy: { createdAt: "desc" }, + take: 5, + select: { id: true, fullName: true, phoneNumber: true, createdAt: true } + }) + ]); + + return ( + +
+ +

+ Pilih file CSV dari browser dan pastikan header minimal: nama, no_telepon, email. +

+
+ +
+
+ +
+

Gunakan channel yang aktif:

+ {channels.length === 0 ? ( +

Tenant belum memiliki channel. Tambahkan channel dulu.

+ ) : ( +
    + {channels.map((channel) => ( +
  • + {channel.channelName} • {channel.provider} +
  • + ))} +
+ )} +
+
+ +
+

Baris terakhir di tenant: {sampleContacts.length} contoh terbaru.

+ {sampleContacts.length === 0 ? ( +

Belum ada contact sebelumnya.

+ ) : ( +
    + {sampleContacts.map((contact) => ( +
  • +

    {contact.fullName}

    +

    {contact.phoneNumber}

    +

    Created: {formatDate(contact.createdAt)}

    +
  • + ))} +
+ )} +

Available tags: {tags.length > 0 ? tags.map((tag) => tag.name).join(", ") : "Belum ada tag."}

+
+
+
+
+ ); +} diff --git a/app/contacts/new/page.tsx b/app/contacts/new/page.tsx new file mode 100644 index 0000000..62a4257 --- /dev/null +++ b/app/contacts/new/page.tsx @@ -0,0 +1,69 @@ +import { ShellPage } from "@/components/page-templates"; +import { Button, SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { createContact } from "@/lib/admin-crud"; +import { prisma } from "@/lib/prisma"; + +export default async function NewContactPage({ + searchParams +}: { + searchParams?: Promise<{ error?: string }>; +}) { + const params = await (searchParams ?? Promise.resolve({ error: undefined })); + const error = params?.error; + const errorMessage = error === "missing_fields" ? "Nama dan nomor wajib diisi." : error === "invalid_channel" ? "Channel tidak valid." : null; + + const session = await getSession(); + const channels = session + ? await prisma.channel.findMany({ + where: { tenantId: session.tenantId }, + orderBy: { channelName: "asc" } + }) + : []; + + return ( + + +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/app/contacts/page.tsx b/app/contacts/page.tsx new file mode 100644 index 0000000..3ab1f8a --- /dev/null +++ b/app/contacts/page.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; + +import { PlaceholderActions, ShellPage } from "@/components/page-templates"; +import { ContactSummaryCards, TablePlaceholder } from "@/components/placeholders"; +import { getContactsData } from "@/lib/platform-data"; +import { deleteContact } from "@/lib/admin-crud"; + +export default async function ContactsPage({ + searchParams +}: { + searchParams?: Promise<{ error?: string }>; +}) { + const params = await (searchParams ?? Promise.resolve({ error: undefined })); + const contacts = await getContactsData(); + const error = params.error; + const infoMessage = error === "contact_not_found" + ? "Contact tidak ditemukan." + : error === "contact_has_conversations" + ? "Contact tidak bisa dihapus karena sudah punya riwayat percakapan." + : error === "invalid_channel" + ? "Channel tidak valid." + : null; + + return ( + } + > + + {infoMessage ?

{infoMessage}

: null} + [ + contact.fullName, + contact.phone, + contact.tags.join(", "), + contact.lastInteraction, + contact.optInStatus, +
+ + Detail + + + Edit + +
+ + +
+
+ ])} + /> +
+ ); +} diff --git a/app/contacts/segments/[segmentId]/page.tsx b/app/contacts/segments/[segmentId]/page.tsx new file mode 100644 index 0000000..5b5ba53 --- /dev/null +++ b/app/contacts/segments/[segmentId]/page.tsx @@ -0,0 +1,108 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { TablePlaceholder } from "@/components/placeholders"; +import { SectionCard } from "@/components/ui"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +function summarizeRules(value: string | null) { + if (!value) { + return "-"; + } + + try { + const parsed = JSON.parse(value); + return parsed.description ? String(parsed.description) : JSON.stringify(parsed); + } catch { + return value; + } +} + +function formatDate(date: Date | null) { + if (!date) { + return "-"; + } + + return new Intl.DateTimeFormat("id-ID", { + day: "2-digit", + month: "short", + year: "numeric" + }).format(date); +} + +export default async function SegmentDetailPage({ params }: { params: Promise<{ segmentId: string }> }) { + const { segmentId } = await params; + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const segment = await prisma.contactSegment.findFirst({ + where: { id: segmentId, tenantId: session.tenantId }, + include: { + _count: { + select: { members: true } + }, + members: { + include: { contact: true }, + orderBy: { createdAt: "desc" }, + take: 20 + }, + campaigns: { + select: { id: true, name: true, status: true, updatedAt: true }, + orderBy: { updatedAt: "desc" } + } + } + }); + + if (!segment) { + redirect("/contacts/segments?error=segment_not_found"); + } + + return ( + Back to segments} + > +
+ +

Nama: {segment.name}

+

Rule: {summarizeRules(segment.description ?? segment.rulesJson)}

+

Members: {segment._count.members}

+

Updated: {formatDate(segment.updatedAt)}

+
+ + {segment.campaigns.length === 0 ? ( +

Tidak ada campaign yang memakai segment ini.

+ ) : ( +
    + {segment.campaigns.map((campaign) => ( +
  • + + {campaign.name} + +

    Status: {campaign.status} • {formatDate(campaign.updatedAt)}

    +
  • + ))} +
+ )} +
+
+ {segment.members.length > 0 ? ( + [ + member.contact.fullName, + member.contact.phoneNumber, + formatDate(member.createdAt) + ])} + /> + ) : null} +
+ ); +} diff --git a/app/contacts/segments/new/page.tsx b/app/contacts/segments/new/page.tsx new file mode 100644 index 0000000..64a2a78 --- /dev/null +++ b/app/contacts/segments/new/page.tsx @@ -0,0 +1,45 @@ +import { redirect } from "next/navigation"; + +import { ShellPage } from "@/components/page-templates"; +import { Button, SectionCard } from "@/components/ui"; +import { createContactSegment } from "@/lib/admin-crud"; +import { getSession } from "@/lib/auth"; + +export default async function NewSegmentPage({ + searchParams +}: { + searchParams?: Promise<{ error?: string }>; +}) { + const session = await getSession(); + if (!session) { + redirect("/login"); + } + + const params = await (searchParams ?? Promise.resolve<{ error?: string }>({})); + const error = params.error; + const errorMessage = error === "missing_fields" ? "Nama segment wajib diisi." : null; + + return ( + + +
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} + +