diff --git a/.codex b/.codex
new file mode 100644
index 0000000..e69de29
diff --git a/.env.example b/.env.example
index 4babd36..2c2c79d 100644
--- a/.env.example
+++ b/.env.example
@@ -27,6 +27,9 @@ 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"
+SESSION_TTL_SECONDS="86400"
+SESSION_COOKIE_DOMAIN=""
+COOKIE_SECURE="true"
AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24"
CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120"
WEBHOOK_EVENT_RETENTION_DAYS="30"
diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts
index 9b952c6..5930c5b 100644
--- a/app/auth/login/route.ts
+++ b/app/auth/login/route.ts
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import {
SESSION_COOKIE,
+ SESSION_COOKIE_SECURE_ENV,
+ getSessionCookieDomain,
+ getSessionTtlSeconds,
UserRole,
canAccessPath,
authenticateUser,
@@ -58,7 +61,7 @@ function maskEmail(email: string) {
}
function shouldUseSecureCookies(request: NextRequest) {
- const explicit = process.env.COOKIE_SECURE?.toLowerCase() ?? "";
+ const explicit = SESSION_COOKIE_SECURE_ENV;
if (explicit === "true" || explicit === "1") {
return true;
}
@@ -193,6 +196,7 @@ export async function POST(request: NextRequest) {
sameSite: "lax",
secure: shouldUseSecureCookies(request),
path: "/",
+ domain: getSessionCookieDomain(),
maxAge: sessionMaxAgeSeconds
});
if (AUTH_DEBUG) {
@@ -200,6 +204,7 @@ export async function POST(request: NextRequest) {
userId: session.userId,
role: session.role,
sessionExpiresAt: session.expiresAt,
+ sessionMaxAgeFromEnv: getSessionTtlSeconds(),
maxAge: sessionMaxAgeSeconds,
host: request.headers.get("host") || "unknown",
protocol: request.nextUrl.protocol,
diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts
index a403263..c3dce31 100644
--- a/app/auth/logout/route.ts
+++ b/app/auth/logout/route.ts
@@ -4,7 +4,7 @@ import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
import { getSession, SESSION_COOKIE } from "@/lib/auth";
import { getRequestBaseUrl } from "@/lib/request-url";
-export async function GET(request: NextRequest) {
+export async function POST(request: NextRequest) {
const session = await getSession();
const { ipAddress, userAgent } = await getRequestAuditContext();
@@ -25,3 +25,9 @@ export async function GET(request: NextRequest) {
response.cookies.delete(SESSION_COOKIE);
return response;
}
+
+export async function GET(request: NextRequest) {
+ const baseUrl = getRequestBaseUrl(request);
+ const response = NextResponse.redirect(new URL("/login", baseUrl));
+ return response;
+}
diff --git a/components/app-shell.tsx b/components/app-shell.tsx
index cb358eb..841d624 100644
--- a/components/app-shell.tsx
+++ b/components/app-shell.tsx
@@ -125,13 +125,15 @@ export async function AppShell({
})}
-
- logout
- {t("nav", "logout")}
-
+
diff --git a/docs/code-documentation.md b/docs/code-documentation.md
new file mode 100644
index 0000000..4b275aa
--- /dev/null
+++ b/docs/code-documentation.md
@@ -0,0 +1,189 @@
+
+
+# WhatsApp Inbox Platform – Dokumentasi Kode
+
+Dokumen ini menjelaskan arsitektur dan alur utama aplikasi supaya mudah dipahami saat onboarding, debug, atau audit.
+
+## 1. Gambaran Sistem
+
+Stack yang dipakai:
+
+- Frontend + backend: Next.js 15 (App Router).
+- Database: PostgreSQL + Prisma ORM.
+- Autentikasi custom: cookie session berbasis payload ter-tanda-tangan.
+- Job worker: campaign retry worker berbasis script Node.
+- Proses produksi: PM2 + Nginx sebagai reverse proxy.
+- Logging dan health check: endpoint internal + script operasi.
+
+## 2. Struktur Folder Penting
+
+`app/`
+Himpunan route Next.js, termasuk endpoint autentikasi dan halaman admin.
+
+`lib/`
+Modul inti:
+
+- auth/session.
+- prisma client.
+- util i18n.
+- request url utility.
+- audit & rate limit.
+
+`prisma/`
+Skema dan migrasi database.
+
+`scripts/`
+Operasional dan maintenance:
+
+- ops scripts.
+- job scripts.
+
+`public/`
+Asset statis.
+
+## 3. Alur Autentikasi
+
+### 3.1 Login
+
+1. POST ke `/auth/login`.
+2. Validasi rate limit, credential, dan status user.
+3. Jika valid, session dibuat dan disimpan di cookie `wa_inbox_session`.
+4. Cookie diset dengan:
+ - `httpOnly`.
+ - `sameSite: "lax"`.
+ - `secure` sesuai env/protocol.
+ - `domain` dari `SESSION_COOKIE_DOMAIN` bila di-set.
+ - `maxAge` dari nilai session TTL.
+5. Response diarahkan ke route default sesuai role.
+
+File:
+- `app/auth/login/route.ts`
+
+### 3.2 Middleware Guard
+
+Setiap request (kecuali API) dilewati middleware untuk validasi:
+
+- Mengecek session cookie.
+- Redirect ke `/login?next=...` jika tidak ada session.
+- Mengarahkan user ke default path role jika sudah login dan masuk halaman publik tertentu.
+- Redirect unauthorized jika role tidak punya akses.
+- Menangani locale cookie fallback.
+
+File:
+- `middleware.ts`
+
+### 3.3 Parse Session
+
+Session token dibuat dari payload user lalu ditandatangani dengan HMAC.
+
+Modul inti:
+- `lib/auth.ts`
+
+Berisi:
+- tipe session.
+- validasi role.
+- serialisasi/deserialisasi cookie.
+- helper konfigurasi session (`getSessionTtlSeconds`, `getSessionCookieDomain`).
+
+## 4. Konfigurasi Session dan Cookie
+
+### 4.1 TTL Session
+
+Nilai diambil dari env:
+
+- `SESSION_TTL_SECONDS` (prioritas utama).
+- `SESSION_TTL_HOURS` (fallback legacy).
+
+Default fallback tetap saat env tidak ada.
+
+### 4.2 Cookie Hardening
+
+Untuk environment HTTPS/prod:
+
+- `COOKIE_SECURE=true`.
+- `SESSION_COOKIE_DOMAIN=web.zappcare.id` (jika pakai subdomain).
+- `SESSION_COOKIE_DOMAIN` akan diabaikan untuk localhost/127.0.0.1.
+
+## 5. Modul dan Script Operasional
+
+### 5.1 Script Deploy Aman
+
+`scripts/ops-safe-restart.sh`:
+
+- cek `.env`.
+- install dependency (`npm ci`).
+- build.
+- deploy migration Prisma (`npm run db:deploy`).
+- restart/start PM2.
+- optional healthcheck.
+- save PM2 state.
+
+NPM script:
+- `npm run ops:safe-restart`
+
+### 5.2 Script Verifikasi Session
+
+`scripts/ops-session-check.mjs`:
+
+- login otomatis via akun health check.
+- verifikasi cookie sesi terbit.
+- validasi TTL.
+- cek akses page protected (`/super-admin`) dengan cookie sesi.
+
+NPM script:
+- `npm run ops:session-check`
+
+### 5.3 Dokumentasi Operasional Terkait
+
+- `ops-runbook.md`
+- `production-readiness-checklist.md`
+
+## 6. Catatan Debug Umum
+
+### Gejala: Kembali ke halaman login setelah klik menu
+
+Penyebab paling umum:
+- sesi tidak tersimpan karena cookie `secure/domain` tidak cocok.
+- domain/protocol HTTPS mismatch.
+- proses logout tidak sengaja terpicu.
+- path/role mismatch pada middleware.
+
+Pengecekan:
+- lihat response headers:
+ - `X-Auth-Session`
+ - `X-Auth-Session-Has-Cookie`
+ - `X-Auth-Base-Url`
+- cek cookie di browser devtools (nama `wa_inbox_session`, secure/domain/path/maxAge).
+- cek log aplikasi (`pm2 logs ...` + `grep AUTH`).
+
+### Gejala: Loop redirect login (bahasa Inggris / error invalid credential)
+
+Pastikan:
+- `AUTH_SECRET` ada di env prod.
+- `APP_URL` / `OPS_BASE_URL` sesuai domain HTTPS.
+- `COOKIE_SECURE` sesuai mode.
+- Nginx tidak strip header `Cookie`.
+
+## 7. Catatan Implementasi Khusus
+
+- Logout flow sudah difokuskan ke request non-prefetch agar tidak terpicu by mistake pada prefetch link.
+- Script yang diubah untuk mencegah kegagalan sesi karena navigasi prefetch.
+- Penambahan debugging header di middleware/login untuk mempercepat root cause analysis.
+
+## 8. Dependency Environment Wajib (Ringkas)
+
+- `DATABASE_URL`
+- `AUTH_SECRET`
+- `APP_URL`
+- `SESSION_TTL_SECONDS`
+- `COOKIE_SECURE`
+- `SESSION_COOKIE_DOMAIN` (jika dibutuhkan)
+- `WHATSAPP_*`, `CAMPAIGN_*`, dan token internal sesuai fitur aktif.
+
+Lihat juga:
+- `ops-runbook.md`
+- `production-readiness-checklist.md`
+- `.env.example`
diff --git a/docs/setup-guide.md b/docs/setup-guide.md
new file mode 100644
index 0000000..b55e4d4
--- /dev/null
+++ b/docs/setup-guide.md
@@ -0,0 +1,320 @@
+
+
+# Guide Setup & Deployment (from zero to production)
+
+Dokumen ini berisi urutan setup dari awal, termasuk instalasi dependency, konfigurasi environment, deploy, dan pengecekan.
+
+## A. Prasyarat Dasar
+
+- Linux server Linux Mint/Ubuntu.
+- User deploy dengan sudo tanpa password (sudah disiapkan di server Anda).
+- Akses SSH ke server.
+- Domain yang mengarah ke server, contoh `web.zappcare.id`.
+- PostgreSQL sudah ada untuk production.
+
+## B. Setup Lokal (Developer)
+
+### B1. Clone dan install
+
+```bash
+git clone https://git.iptek.co/wirabasalamah/whatsapp-inbox-platform.git
+cd whatsapp-inbox-platform
+npm install
+```
+
+### B2. Siapkan `.env`
+
+```bash
+cp .env.example .env
+```
+
+Edit `.env` minimal:
+
+- `DATABASE_URL`
+- `AUTH_SECRET`
+- `APP_URL=http://localhost:3002`
+- `SESSION_TTL_SECONDS=86400`
+- `COOKIE_SECURE=false` untuk lokal non-HTTPS
+
+### B3. Jalankan DB lokal
+
+```bash
+npx prisma generate
+npm run db:migrate
+npm run db:seed
+```
+
+### B4. Jalankan aplikasi
+
+```bash
+npm run dev
+```
+
+Untuk build check:
+
+```bash
+npm run build
+```
+
+## C. Setup Server (Dari Kosong)
+
+## C1. Install Node.js + Git + Nginx + PostgreSQL + PM2
+
+```bash
+sudo apt update
+sudo apt install -y curl git nginx postgresql
+curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+sudo apt install -y nodejs
+sudo npm install -g pm2
+```
+
+Catatan:
+
+- Versi Node.js disesuaikan dengan kebutuhan proyek.
+- Gunakan `pnpm` jika Anda standardize npm/pnpm di server, tapi repo ini diuji dengan npm.
+
+## C2. Setup user deploy
+
+Contoh:
+
+```bash
+sudo adduser --system --group whatsapp-inbox
+sudo mkdir -p /var/www/whatsapp-inbox-platform
+sudo chown -R whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox-platform
+```
+
+## C3. Clone repo dan set permission
+
+```bash
+sudo -u whatsapp-inbox -H git clone https://git.iptek.co/wirabasalamah/whatsapp-inbox-platform.git /var/www/whatsapp-inbox-platform
+sudo chown -R whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox-platform
+sudo chmod -R g+rwX /var/www/whatsapp-inbox-platform
+```
+
+## C4. Konfigurasi Database PostgreSQL
+
+```sql
+-- psql as postgres
+CREATE DATABASE whatsapp_inbox;
+CREATE USER whatsapp_inbox WITH ENCRYPTED PASSWORD 'isi_password_kuat';
+GRANT ALL PRIVILEGES ON DATABASE whatsapp_inbox TO whatsapp_inbox;
+```
+
+Pastikan `DATABASE_URL` mengarah ke:
+
+`postgresql://whatsapp_inbox:password@127.0.0.1:5432/whatsapp_inbox?schema=public`
+
+## C5. Set `.env` production
+
+Buat `.env` di `/var/www/whatsapp-inbox-platform/.env` berdasarkan `.env.example`.
+
+Wajib isi:
+
+- `NODE_ENV=production`
+- `PORT=3002`
+- `DATABASE_URL=...`
+- `AUTH_SECRET=...` (random panjang)
+- `APP_URL=https://web.zappcare.id`
+- `SESSION_TTL_SECONDS=86400`
+- `COOKIE_SECURE=true`
+- `SESSION_COOKIE_DOMAIN=web.zappcare.id`
+- `CAMPAIGN_RETRY_JOB_TOKEN=...`
+- `WHATSAPP_WEBHOOK_VERIFY_TOKEN=...`
+- `WHATSAPP_WEBHOOK_SECRET=...`
+
+Opsional:
+
+- `OPS_SESSION_CHECK_EMAIL`, `OPS_SESSION_CHECK_PASSWORD` untuk verifikasi otomatis.
+
+## C6. Build dan Deploy awal
+
+```bash
+cd /var/www/whatsapp-inbox-platform
+npm ci
+npm run build
+npm run db:deploy
+npm run ops:safe-restart
+```
+
+`ops:safe-restart` juga melakukan install+build+migrate jika dipanggil dari state normal.
+
+## C7. Nginx + SSL
+
+Konfigurasi Nginx (contoh sederhana):
+
+```nginx
+server {
+ listen 80;
+ server_name web.zappcare.id;
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name web.zappcare.id;
+
+ ssl_certificate /etc/letsencrypt/live/web.zappcare.id/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/web.zappcare.id/privkey.pem;
+
+ location / {
+ proxy_pass http://127.0.0.1:3002;
+ proxy_http_version 1.1;
+ 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;
+ }
+}
+```
+
+Setup Let’s Encrypt:
+
+```bash
+sudo apt install -y certbot python3-certbot-nginx
+sudo certbot --nginx -d web.zappcare.id
+```
+
+Reload nginx:
+
+```bash
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+## D. Deployment Routine Setelah Update
+
+### D0. Alur source code ke Git
+
+Kerjakan perubahan di folder lokal:
+
+```bash
+cd /home/wira/work/whatsapp-inbox-platform
+```
+
+Pastikan file sensitif seperti `.env` tidak ikut di-commit. Repo ini sudah meng-ignore `.env`.
+
+Command Git manual dari lokal:
+
+```bash
+cd /home/wira/work/whatsapp-inbox-platform
+git status
+git add .
+git commit -m "fix: deskripsi perubahan"
+git push origin main
+```
+
+Jika ingin cek commit terakhir yang sudah siap diambil server:
+
+```bash
+cd /home/wira/work/whatsapp-inbox-platform
+git log --oneline -n 5
+```
+
+### D1. Pull dan jalankan restart aman
+
+```bash
+cd /var/www/whatsapp-inbox-platform
+git pull origin main
+npm run ops:safe-restart
+```
+
+Jika app di server dijalankan dengan user deploy `whatsapp-inbox`, gunakan:
+
+```bash
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main && npm run ops:safe-restart'
+```
+
+Command di atas adalah jalur patch utama untuk update source di server.
+
+### D1.1. Patch manual di server langkah demi langkah
+
+Gunakan ini jika ingin melihat proses satu per satu:
+
+```bash
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git status'
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main'
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm ci'
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run build'
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run db:deploy'
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run ops:safe-restart'
+```
+
+Gunakan jalur manual ini jika:
+
+- ada perubahan dependency baru
+- ada migration baru
+- ingin memastikan step build berhasil sebelum restart
+
+### D1.2. Patch cepat tanpa perubahan dependency
+
+Kalau hanya ubah source biasa dan `package-lock.json` tidak berubah:
+
+```bash
+sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main && npm run build && npm run ops:safe-restart'
+```
+
+### D2. Verifikasi pasca deploy
+
+```bash
+cd /var/www/whatsapp-inbox-platform
+npm run ops:readiness
+npm run ops:session-check
+npm run ops:healthcheck
+```
+
+### D3. Verifikasi proses berjalan
+
+```bash
+pm2 list
+sudo ss -ltnp | grep 3002
+```
+
+## E. Checklist Pasca Login Issue
+
+Jika masih kena loop ke login:
+
+- cek cookie `wa_inbox_session` di browser.
+- cek domain/cookie secure.
+- cek header:
+ - `X-Auth-Session`
+ - `X-Auth-Session-Has-Cookie`
+ - `X-Auth-Base-Url`
+- cek logs:
+
+```bash
+pm2 logs whatsapp-inbox-platform --lines 200
+```
+
+- cek ENV:
+ - `COOKIE_SECURE=true` untuk HTTPS.
+ - `APP_URL` harus `https://web.zappcare.id`.
+ - `SESSION_COOKIE_DOMAIN` kalau lintas subdomain.
+
+## F. Jalur Emergency/Recovery
+
+Jika app tidak start:
+
+- `npm run ops:readiness`
+- cek DB connect.
+- jalankan ulang migration jika schema mismatch.
+- run `npm run ops:safe-restart`.
+- jika perlu rollback, checkout commit sebelumnya lalu restart ulang.
+
+```bash
+git log --oneline -n 10
+git checkout
+npm run ops:safe-restart
+```
+
+## G. Reference Command Cepat
+
+- Health app: `npm run ops:healthcheck`
+- Session check: `npm run ops:session-check`
+- Ready check: `npm run ops:readiness`
+- Restart aman: `npm run ops:safe-restart`
+- View process: `pm2 list`
+- Tail logs: `pm2 logs`
+- Folder kerja lokal: `/home/wira/work/whatsapp-inbox-platform`
+- Folder deploy server: `/var/www/whatsapp-inbox-platform`
diff --git a/lib/auth.ts b/lib/auth.ts
index 3ebba19..52bc132 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -19,7 +19,46 @@ export type AuthSession = {
};
export const SESSION_COOKIE = "wa_inbox_session";
-const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
+const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
+const SESSION_TTL_SECONDS = getConfiguredSessionTtlSeconds(process.env.SESSION_TTL_SECONDS);
+export const SESSION_COOKIE_DOMAIN = process.env.SESSION_COOKIE_DOMAIN?.trim() || "";
+export const SESSION_COOKIE_SECURE_ENV = process.env.COOKIE_SECURE?.trim().toLowerCase() || "";
+
+function getConfiguredSessionTtlSeconds(raw: string | undefined) {
+ if (typeof raw === "string" && raw.trim().length > 0) {
+ const parsed = Number(raw.trim());
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return Math.floor(parsed);
+ }
+ }
+
+ const legacyHours = Number(process.env.SESSION_TTL_HOURS);
+ if (Number.isFinite(legacyHours) && legacyHours > 0) {
+ return Math.floor(legacyHours * 60 * 60);
+ }
+
+ return DEFAULT_SESSION_TTL_SECONDS;
+}
+
+export function getSessionTtlSeconds() {
+ return SESSION_TTL_SECONDS;
+}
+
+function parseCookieDomain() {
+ if (!SESSION_COOKIE_DOMAIN) {
+ return undefined;
+ }
+
+ if (SESSION_COOKIE_DOMAIN === "localhost" || SESSION_COOKIE_DOMAIN === "127.0.0.1") {
+ return undefined;
+ }
+
+ return SESSION_COOKIE_DOMAIN;
+}
+
+export function getSessionCookieDomain() {
+ return parseCookieDomain();
+}
const AUTH_SECRET = process.env.AUTH_SECRET;
const SESSION_ITERATIONS = 120000;
@@ -238,8 +277,8 @@ export async function parseSessionCookie(raw: string) {
return {
userId,
- role: role as UserRole,
- tenantId,
+ role: role as UserRole,
+ tenantId,
tenantName: "",
fullName: "",
email: "",
diff --git a/lib/inbox-ops.ts b/lib/inbox-ops.ts
index 2440a44..3ad0242 100644
--- a/lib/inbox-ops.ts
+++ b/lib/inbox-ops.ts
@@ -330,15 +330,17 @@ export async function getInboxWorkspace({ scope, conversationId, filter = "all"
return {
id: conversation.id,
tenantId: conversation.tenantId,
- name: conversation.contact.fullName,
- phone: conversation.contact.phoneNumber,
+ name: conversation.contact?.fullName ?? "Unknown Contact",
+ phone: conversation.contact?.phoneNumber ?? "-",
snippet: latestMessage?.contentText ?? conversation.subject ?? "No content",
time: formatRelativeDate(conversation.lastMessageAt),
status: mapConversationStatus(conversation.status),
assignee: conversation.assignedUser?.fullName ?? "Unassigned",
assigneeId: conversation.assignedUser?.id ?? null,
- channel: conversation.channel.channelName,
- tags: conversation.conversationTags.map((item) => item.tag.name),
+ channel: conversation.channel?.channelName ?? "Unknown Channel",
+ tags: conversation.conversationTags
+ .map((item) => item.tag?.name)
+ .filter((tag): tag is string => Boolean(tag)),
priority: mapConversationPriority(conversation.priority),
contactId: conversation.contactId
} satisfies ConversationSummary;
@@ -394,17 +396,19 @@ export async function getInboxWorkspace({ scope, conversationId, filter = "all"
? {
id: fullConversation.id,
tenantId: fullConversation.tenantId,
- name: fullConversation.contact.fullName,
- phone: fullConversation.contact.phoneNumber,
+ name: fullConversation.contact?.fullName ?? "Unknown Contact",
+ phone: fullConversation.contact?.phoneNumber ?? "-",
snippet: fullConversation.subject ?? fullConversation.messages[0]?.contentText ?? "No content",
time: formatRelativeDate(fullConversation.lastMessageAt),
status: mapConversationStatus(fullConversation.status),
assignee: fullConversation.assignedUser?.fullName ?? "Unassigned",
assigneeId: fullConversation.assignedUser?.id ?? null,
- channel: fullConversation.channel.channelName,
- tags: fullConversation.conversationTags.map((item) => item.tag.name),
+ channel: fullConversation.channel?.channelName ?? "Unknown Channel",
+ tags: fullConversation.conversationTags
+ .map((item) => item.tag?.name)
+ .filter((tag): tag is string => Boolean(tag)),
priority: mapConversationPriority(fullConversation.priority),
- contactId: fullConversation.contact.id,
+ contactId: fullConversation.contact?.id ?? fullConversation.contactId,
tagJson: JSON.stringify(fullConversation.conversationTags.map((item) => item.tag.name)),
messages: fullConversation.messages
.slice()
diff --git a/middleware.ts b/middleware.ts
index cdbc71e..ee9a585 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -3,6 +3,7 @@ import { NextResponse, type NextRequest } from "next/server";
import { canAccessPath, getDefaultPathForRole, parseSessionCookie, SESSION_COOKIE, type UserRole } from "@/lib/auth";
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n";
import { getRequestBaseUrl } from "@/lib/request-url";
+import { getSessionCookieDomain } from "@/lib/auth";
const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1";
@@ -73,6 +74,7 @@ export async function middleware(request: NextRequest) {
const detected = acceptLanguage.includes("id") ? "id" : acceptLanguage.includes("en") ? "en" : DEFAULT_LOCALE;
response.cookies.set(LOCALE_COOKIE, detected, {
path: "/",
+ domain: getSessionCookieDomain(),
maxAge: 365 * 24 * 60 * 60,
secure: shouldUseSecureCookies(request),
sameSite: "lax"
@@ -80,6 +82,7 @@ export async function middleware(request: NextRequest) {
} else if (!isLocale(localeCookie)) {
response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, {
path: "/",
+ domain: getSessionCookieDomain(),
maxAge: 365 * 24 * 60 * 60,
secure: shouldUseSecureCookies(request),
sameSite: "lax"
diff --git a/ops-runbook.md b/ops-runbook.md
index 15c28bc..01fb642 100644
--- a/ops-runbook.md
+++ b/ops-runbook.md
@@ -2,13 +2,23 @@
## 1) Normal Deployment
-- Deploy code.
+- Deploy code:
+- optional pre-step on server:
+ - `git pull`
+ - `npm ci`
+ - `npm run build`
- Run migration:
- `npm run db:deploy`
+- Optional schema safety (if DB changes):
+ - `npm run ops:readiness`
- Update environment variables and secrets.
-- Start app service.
+- Start or restart app service:
+ - `npm run ops:safe-restart`
- Start retry worker (`daemon` or cron).
- Start maintenance cleanup (`npm run ops:maintenance`) on a periodic schedule, e.g. daily.
+- Verify auth session baseline (24h check target default can be tuned via `SESSION_TTL_SECONDS`):
+ - set `OPS_SESSION_CHECK_EMAIL` dan `OPS_SESSION_CHECK_PASSWORD` di `.env`
+ - `npm run ops:session-check`
- Run readiness:
- `npm run ops:readiness`
- Monitor:
diff --git a/package.json b/package.json
index fe7bada..e4de40a 100644
--- a/package.json
+++ b/package.json
@@ -20,8 +20,10 @@
"ops:healthcheck": "node scripts/ops-healthcheck.mjs",
"ops:readiness": "node scripts/ops-readiness.mjs",
"ops:incident": "node scripts/ops-incident.mjs",
+ "ops:session-check": "node scripts/ops-session-check.mjs",
"ops:smoke": "node scripts/ops-smoke.mjs",
"ops:maintenance": "node scripts/ops-maintenance.mjs",
+ "ops:safe-restart": "bash scripts/ops-safe-restart.sh",
"ci:verify": "npm run typecheck && npm run lint:ci && npm run build"
},
"prisma": {
diff --git a/production-readiness-checklist.md b/production-readiness-checklist.md
index a4bf10b..d05024c 100644
--- a/production-readiness-checklist.md
+++ b/production-readiness-checklist.md
@@ -10,6 +10,8 @@
- `APP_URL` (atau `OPS_BASE_URL`)
- `WHATSAPP_WEBHOOK_VERIFY_TOKEN` + `WHATSAPP_WEBHOOK_SECRET`
- `CAMPAIGN_RETRY_JOB_TOKEN`
+ - `SESSION_TTL_SECONDS` (opsional, contoh `86400` untuk 24 jam)
+ - `SESSION_COOKIE_DOMAIN` (opsional untuk cookie shared subdomain)
- optional production hardening:
- `HEALTHCHECK_TOKEN`
- `WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR`
@@ -24,6 +26,8 @@
- [ ] Run `npm run typecheck`.
- [ ] Run `npm run build`.
- [ ] Run `npm run ops:smoke` sebelum deploy dan setelah restart service.
+- [ ] Jika aplikasi pakai PM2, verifikasi restart aman:
+ - `npm run ops:safe-restart` (hanya di server, setelah cek git/env terbaru).
- [ ] Review `.env` tidak mengandung placeholder (contoh `change-me`, `your-*`).
## Runtime Readiness (Post-Deploy)
@@ -35,6 +39,9 @@
- run `npm run ops:maintenance` on a broader schedule (daily/weekly) for cleanup.
- [ ] Run ops readiness: `npm run ops:readiness`.
- [ ] Check endpoint health: `GET /api/health`.
+- [ ] Verify sesi login valid dan cookie lifetime:
+ - set `OPS_SESSION_CHECK_EMAIL` dan `OPS_SESSION_CHECK_PASSWORD`
+ - `npm run ops:session-check`
- [ ] Verify webhook endpoint reachable: `POST /api/webhooks/whatsapp`.
- [ ] Verify campaign retry endpoint state:
- `GET /api/jobs/campaign-retry?token=`
diff --git a/scripts/ops-safe-restart.sh b/scripts/ops-safe-restart.sh
new file mode 100755
index 0000000..dd5bcee
--- /dev/null
+++ b/scripts/ops-safe-restart.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+APP_DIR=${APP_DIR:-$(pwd)}
+PM2_NAME=${PM2_NAME:-whatsapp-inbox-platform}
+PORT=${PORT:-3002}
+SKIP_DB=${SKIP_DB:-0}
+SKIP_HEALTHCHECK=${SKIP_HEALTHCHECK:-0}
+
+if [ ! -f "$APP_DIR/.env" ]; then
+ echo "[ops-safe-restart] .env file not found in $APP_DIR"
+ exit 1
+fi
+
+cd "$APP_DIR"
+
+echo "[ops-safe-restart] install dependencies"
+npm ci --no-audit --no-fund
+
+echo "[ops-safe-restart] run build"
+npm run build
+
+if [ "$SKIP_DB" != "1" ]; then
+ echo "[ops-safe-restart] apply prisma migration"
+ npm run db:deploy
+fi
+
+if pm2 describe "$PM2_NAME" >/dev/null 2>&1; then
+ echo "[ops-safe-restart] restart existing pm2 process: $PM2_NAME"
+ pm2 restart "$PM2_NAME" --update-env
+else
+ echo "[ops-safe-restart] start new pm2 process: $PM2_NAME"
+ pm2 start npm --name "$PM2_NAME" -- start -- --port "$PORT"
+fi
+
+if [ "$SKIP_HEALTHCHECK" != "1" ]; then
+ echo "[ops-safe-restart] run ops healthcheck"
+ npm run ops:healthcheck
+fi
+
+echo "[ops-safe-restart] saving pm2 process list"
+pm2 save
+
+echo "[ops-safe-restart] done"
diff --git a/scripts/ops-session-check.mjs b/scripts/ops-session-check.mjs
new file mode 100644
index 0000000..727879d
--- /dev/null
+++ b/scripts/ops-session-check.mjs
@@ -0,0 +1,189 @@
+#!/usr/bin/env node
+
+const BASE_URL = [
+ process.env.OPS_BASE_URL,
+ process.env.APP_URL,
+ process.env.NEXT_PUBLIC_APP_URL
+].map((value) => value?.trim()).find(Boolean);
+
+const TEST_EMAIL = process.env.OPS_SESSION_CHECK_EMAIL?.trim();
+const TEST_PASSWORD = process.env.OPS_SESSION_CHECK_PASSWORD?.trim();
+const EXPECTED_TTL_SECONDS = resolveSessionTtl(process.env.SESSION_TTL_SECONDS);
+const SESSION_COOKIE_NAME = "wa_inbox_session";
+
+if (!BASE_URL) {
+ console.error("[ops-session-check] Missing OPS_BASE_URL / APP_URL / NEXT_PUBLIC_APP_URL");
+ process.exit(1);
+}
+
+if (!TEST_EMAIL || !TEST_PASSWORD) {
+ console.error("[ops-session-check] Missing OPS_SESSION_CHECK_EMAIL / OPS_SESSION_CHECK_PASSWORD. Test skipped.");
+ process.exit(1);
+}
+
+function resolveSessionTtl(value) {
+ if (!value) {
+ return 60 * 60 * 24 * 7;
+ }
+ const parsed = Number(value.trim());
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return 60 * 60 * 24 * 7;
+ }
+ return Math.floor(parsed);
+}
+
+function readCookieLines(headers) {
+ const direct = [];
+ if (typeof headers.getSetCookie === "function") {
+ const lines = headers.getSetCookie();
+ if (Array.isArray(lines)) {
+ return lines;
+ }
+ }
+
+ headers.forEach((value, key) => {
+ if (key.toLowerCase() === "set-cookie") {
+ direct.push(value);
+ }
+ });
+
+ return direct;
+}
+
+function getCookieValue(cookieHeader, name) {
+ const prefix = `${name}=`;
+ const found = cookieHeader.find((entry) => entry.startsWith(prefix));
+ if (!found) {
+ return null;
+ }
+
+ const [value] = found.split(";")[0].split("=", 2).slice(1);
+ if (typeof value !== "string") {
+ return null;
+ }
+ return `${found.split(";")[0].slice(prefix.length)}`;
+}
+
+function getCookieTtlSeconds(cookieHeader, name) {
+ const prefix = `${name}=`;
+ const found = cookieHeader.find((entry) => entry.startsWith(prefix));
+ if (!found) {
+ return null;
+ }
+
+ const parts = found.split(";").map((part) => part.trim());
+ const attrs = new Map();
+ for (const part of parts.slice(1)) {
+ const [rawKey, rawValue] = part.split("=");
+ attrs.set(rawKey.toLowerCase(), rawValue ?? "");
+ }
+
+ const maxAgeRaw = attrs.get("max-age");
+ if (maxAgeRaw) {
+ const parsed = Number(maxAgeRaw);
+ if (Number.isFinite(parsed)) {
+ return Math.floor(parsed);
+ }
+ }
+
+ const expiresRaw = attrs.get("expires");
+ if (expiresRaw) {
+ const parsedDate = new Date(expiresRaw);
+ if (!Number.isNaN(parsedDate.getTime())) {
+ return Math.floor((parsedDate.getTime() - Date.now()) / 1000);
+ }
+ }
+
+ return null;
+}
+
+async function requestJson(url, options = {}) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 12_000);
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ signal: controller.signal
+ });
+ clearTimeout(timeout);
+ return response;
+ } catch (error) {
+ clearTimeout(timeout);
+ throw error;
+ }
+}
+
+async function main() {
+ const loginHeaders = {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "User-Agent": "ops-session-check"
+ };
+ const loginBody = new URLSearchParams({
+ email: TEST_EMAIL,
+ password: TEST_PASSWORD,
+ next: "/super-admin"
+ }).toString();
+
+ const loginResponse = await requestJson(`${BASE_URL}/auth/login`, {
+ method: "POST",
+ redirect: "manual",
+ headers: loginHeaders,
+ body: loginBody
+ });
+
+ if (loginResponse.status !== 307 && loginResponse.status !== 302) {
+ console.error(`[ops-session-check] login status unexpected: ${loginResponse.status}`);
+ process.exit(1);
+ }
+
+ const setCookieLines = readCookieLines(loginResponse.headers);
+ const sessionCookieValue = getCookieValue(setCookieLines, SESSION_COOKIE_NAME);
+ if (!sessionCookieValue) {
+ console.error("[ops-session-check] session cookie not issued by /auth/login");
+ process.exit(1);
+ }
+
+ const ttl = getCookieTtlSeconds(setCookieLines, SESSION_COOKIE_NAME);
+ if (ttl === null) {
+ console.error("[ops-session-check] session cookie ttl missing");
+ process.exit(1);
+ }
+
+ if (Math.abs(ttl - EXPECTED_TTL_SECONDS) > 600) {
+ console.warn(
+ `[ops-session-check] session ttl unexpected: ${ttl}s (expected ${EXPECTED_TTL_SECONDS}s ±600s)`
+ );
+ } else {
+ console.log(`[ops-session-check] session ttl check OK: ${ttl}s`);
+ }
+
+ const protected = await requestJson(`${BASE_URL}/super-admin`, {
+ method: "GET",
+ redirect: "manual",
+ headers: {
+ Cookie: `${SESSION_COOKIE_NAME}=${sessionCookieValue}`
+ }
+ });
+
+ if (protected.status === 200) {
+ console.log("[ops-session-check] protected path access OK (HTTP 200).");
+ process.exit(0);
+ }
+
+ if (protected.status >= 300 && protected.status < 400) {
+ const location = protected.headers.get("location") || "";
+ if (location.includes("/login")) {
+ console.error("[ops-session-check] protected path redirected to login; session not accepted.");
+ process.exit(1);
+ }
+ }
+
+ console.error(`[ops-session-check] protected path check failed with status ${protected.status}`);
+ process.exit(1);
+}
+
+main().catch((error) => {
+ console.error("[ops-session-check] failed:", error instanceof Error ? error.message : String(error));
+ process.exit(1);
+});