fix: lates
Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run

This commit is contained in:
2026-04-21 20:37:59 +07:00
parent f48c87e36d
commit 137edc12b7
15 changed files with 846 additions and 23 deletions

0
.codex Normal file
View File

View File

@ -27,6 +27,9 @@ CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS="60000"
WHATSAPP_WEBHOOK_RATE_LIMIT_GET="60" WHATSAPP_WEBHOOK_RATE_LIMIT_GET="60"
WHATSAPP_WEBHOOK_RATE_LIMIT_POST="120" WHATSAPP_WEBHOOK_RATE_LIMIT_POST="120"
WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS="60000" WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS="60000"
SESSION_TTL_SECONDS="86400"
SESSION_COOKIE_DOMAIN=""
COOKIE_SECURE="true"
AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24" AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24"
CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120" CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120"
WEBHOOK_EVENT_RETENTION_DAYS="30" WEBHOOK_EVENT_RETENTION_DAYS="30"

View File

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
SESSION_COOKIE, SESSION_COOKIE,
SESSION_COOKIE_SECURE_ENV,
getSessionCookieDomain,
getSessionTtlSeconds,
UserRole, UserRole,
canAccessPath, canAccessPath,
authenticateUser, authenticateUser,
@ -58,7 +61,7 @@ function maskEmail(email: string) {
} }
function shouldUseSecureCookies(request: NextRequest) { function shouldUseSecureCookies(request: NextRequest) {
const explicit = process.env.COOKIE_SECURE?.toLowerCase() ?? ""; const explicit = SESSION_COOKIE_SECURE_ENV;
if (explicit === "true" || explicit === "1") { if (explicit === "true" || explicit === "1") {
return true; return true;
} }
@ -193,6 +196,7 @@ export async function POST(request: NextRequest) {
sameSite: "lax", sameSite: "lax",
secure: shouldUseSecureCookies(request), secure: shouldUseSecureCookies(request),
path: "/", path: "/",
domain: getSessionCookieDomain(),
maxAge: sessionMaxAgeSeconds maxAge: sessionMaxAgeSeconds
}); });
if (AUTH_DEBUG) { if (AUTH_DEBUG) {
@ -200,6 +204,7 @@ export async function POST(request: NextRequest) {
userId: session.userId, userId: session.userId,
role: session.role, role: session.role,
sessionExpiresAt: session.expiresAt, sessionExpiresAt: session.expiresAt,
sessionMaxAgeFromEnv: getSessionTtlSeconds(),
maxAge: sessionMaxAgeSeconds, maxAge: sessionMaxAgeSeconds,
host: request.headers.get("host") || "unknown", host: request.headers.get("host") || "unknown",
protocol: request.nextUrl.protocol, protocol: request.nextUrl.protocol,

View File

@ -4,7 +4,7 @@ import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
import { getSession, SESSION_COOKIE } from "@/lib/auth"; import { getSession, SESSION_COOKIE } from "@/lib/auth";
import { getRequestBaseUrl } from "@/lib/request-url"; import { getRequestBaseUrl } from "@/lib/request-url";
export async function GET(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await getSession(); const session = await getSession();
const { ipAddress, userAgent } = await getRequestAuditContext(); const { ipAddress, userAgent } = await getRequestAuditContext();
@ -25,3 +25,9 @@ export async function GET(request: NextRequest) {
response.cookies.delete(SESSION_COOKIE); response.cookies.delete(SESSION_COOKIE);
return response; return response;
} }
export async function GET(request: NextRequest) {
const baseUrl = getRequestBaseUrl(request);
const response = NextResponse.redirect(new URL("/login", baseUrl));
return response;
}

View File

@ -125,13 +125,15 @@ export async function AppShell({
})} })}
</nav> </nav>
<div className="mt-auto border-t border-line px-2 pt-5"> <div className="mt-auto border-t border-line px-2 pt-5">
<Link <form action="/auth/logout" method="post" className="w-full">
href="/auth/logout" <button
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-semibold text-on-surface-variant transition hover:text-on-surface" type="submit"
className="flex w-full items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-semibold text-on-surface-variant transition hover:text-on-surface"
> >
<span className="material-symbols-outlined text-sm">logout</span> <span className="material-symbols-outlined text-sm">logout</span>
<span>{t("nav", "logout")}</span> <span>{t("nav", "logout")}</span>
</Link> </button>
</form>
</div> </div>
</aside> </aside>
<div className="flex min-h-screen flex-1 flex-col"> <div className="flex min-h-screen flex-1 flex-col">

189
docs/code-documentation.md Normal file
View File

@ -0,0 +1,189 @@
<!--
Project Documentation
Scope: WhatsApp Inbox Platform
-->
# 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`

320
docs/setup-guide.md Normal file
View File

@ -0,0 +1,320 @@
<!--
Setup & Deployment Guide
Scope: Local and Production
-->
# 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 Lets 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 <commit_id_aman>
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`

View File

@ -19,7 +19,46 @@ export type AuthSession = {
}; };
export const SESSION_COOKIE = "wa_inbox_session"; 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 AUTH_SECRET = process.env.AUTH_SECRET;
const SESSION_ITERATIONS = 120000; const SESSION_ITERATIONS = 120000;

View File

@ -330,15 +330,17 @@ export async function getInboxWorkspace({ scope, conversationId, filter = "all"
return { return {
id: conversation.id, id: conversation.id,
tenantId: conversation.tenantId, tenantId: conversation.tenantId,
name: conversation.contact.fullName, name: conversation.contact?.fullName ?? "Unknown Contact",
phone: conversation.contact.phoneNumber, phone: conversation.contact?.phoneNumber ?? "-",
snippet: latestMessage?.contentText ?? conversation.subject ?? "No content", snippet: latestMessage?.contentText ?? conversation.subject ?? "No content",
time: formatRelativeDate(conversation.lastMessageAt), time: formatRelativeDate(conversation.lastMessageAt),
status: mapConversationStatus(conversation.status), status: mapConversationStatus(conversation.status),
assignee: conversation.assignedUser?.fullName ?? "Unassigned", assignee: conversation.assignedUser?.fullName ?? "Unassigned",
assigneeId: conversation.assignedUser?.id ?? null, assigneeId: conversation.assignedUser?.id ?? null,
channel: conversation.channel.channelName, channel: conversation.channel?.channelName ?? "Unknown Channel",
tags: conversation.conversationTags.map((item) => item.tag.name), tags: conversation.conversationTags
.map((item) => item.tag?.name)
.filter((tag): tag is string => Boolean(tag)),
priority: mapConversationPriority(conversation.priority), priority: mapConversationPriority(conversation.priority),
contactId: conversation.contactId contactId: conversation.contactId
} satisfies ConversationSummary; } satisfies ConversationSummary;
@ -394,17 +396,19 @@ export async function getInboxWorkspace({ scope, conversationId, filter = "all"
? { ? {
id: fullConversation.id, id: fullConversation.id,
tenantId: fullConversation.tenantId, tenantId: fullConversation.tenantId,
name: fullConversation.contact.fullName, name: fullConversation.contact?.fullName ?? "Unknown Contact",
phone: fullConversation.contact.phoneNumber, phone: fullConversation.contact?.phoneNumber ?? "-",
snippet: fullConversation.subject ?? fullConversation.messages[0]?.contentText ?? "No content", snippet: fullConversation.subject ?? fullConversation.messages[0]?.contentText ?? "No content",
time: formatRelativeDate(fullConversation.lastMessageAt), time: formatRelativeDate(fullConversation.lastMessageAt),
status: mapConversationStatus(fullConversation.status), status: mapConversationStatus(fullConversation.status),
assignee: fullConversation.assignedUser?.fullName ?? "Unassigned", assignee: fullConversation.assignedUser?.fullName ?? "Unassigned",
assigneeId: fullConversation.assignedUser?.id ?? null, assigneeId: fullConversation.assignedUser?.id ?? null,
channel: fullConversation.channel.channelName, channel: fullConversation.channel?.channelName ?? "Unknown Channel",
tags: fullConversation.conversationTags.map((item) => item.tag.name), tags: fullConversation.conversationTags
.map((item) => item.tag?.name)
.filter((tag): tag is string => Boolean(tag)),
priority: mapConversationPriority(fullConversation.priority), priority: mapConversationPriority(fullConversation.priority),
contactId: fullConversation.contact.id, contactId: fullConversation.contact?.id ?? fullConversation.contactId,
tagJson: JSON.stringify(fullConversation.conversationTags.map((item) => item.tag.name)), tagJson: JSON.stringify(fullConversation.conversationTags.map((item) => item.tag.name)),
messages: fullConversation.messages messages: fullConversation.messages
.slice() .slice()

View File

@ -3,6 +3,7 @@ import { NextResponse, type NextRequest } from "next/server";
import { canAccessPath, getDefaultPathForRole, parseSessionCookie, SESSION_COOKIE, type UserRole } from "@/lib/auth"; import { canAccessPath, getDefaultPathForRole, parseSessionCookie, SESSION_COOKIE, type UserRole } from "@/lib/auth";
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n"; import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n";
import { getRequestBaseUrl } from "@/lib/request-url"; import { getRequestBaseUrl } from "@/lib/request-url";
import { getSessionCookieDomain } from "@/lib/auth";
const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1"; 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; const detected = acceptLanguage.includes("id") ? "id" : acceptLanguage.includes("en") ? "en" : DEFAULT_LOCALE;
response.cookies.set(LOCALE_COOKIE, detected, { response.cookies.set(LOCALE_COOKIE, detected, {
path: "/", path: "/",
domain: getSessionCookieDomain(),
maxAge: 365 * 24 * 60 * 60, maxAge: 365 * 24 * 60 * 60,
secure: shouldUseSecureCookies(request), secure: shouldUseSecureCookies(request),
sameSite: "lax" sameSite: "lax"
@ -80,6 +82,7 @@ export async function middleware(request: NextRequest) {
} else if (!isLocale(localeCookie)) { } else if (!isLocale(localeCookie)) {
response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, { response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, {
path: "/", path: "/",
domain: getSessionCookieDomain(),
maxAge: 365 * 24 * 60 * 60, maxAge: 365 * 24 * 60 * 60,
secure: shouldUseSecureCookies(request), secure: shouldUseSecureCookies(request),
sameSite: "lax" sameSite: "lax"

View File

@ -2,13 +2,23 @@
## 1) Normal Deployment ## 1) Normal Deployment
- Deploy code. - Deploy code:
- optional pre-step on server:
- `git pull`
- `npm ci`
- `npm run build`
- Run migration: - Run migration:
- `npm run db:deploy` - `npm run db:deploy`
- Optional schema safety (if DB changes):
- `npm run ops:readiness`
- Update environment variables and secrets. - 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 retry worker (`daemon` or cron).
- Start maintenance cleanup (`npm run ops:maintenance`) on a periodic schedule, e.g. daily. - 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: - Run readiness:
- `npm run ops:readiness` - `npm run ops:readiness`
- Monitor: - Monitor:

View File

@ -20,8 +20,10 @@
"ops:healthcheck": "node scripts/ops-healthcheck.mjs", "ops:healthcheck": "node scripts/ops-healthcheck.mjs",
"ops:readiness": "node scripts/ops-readiness.mjs", "ops:readiness": "node scripts/ops-readiness.mjs",
"ops:incident": "node scripts/ops-incident.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:smoke": "node scripts/ops-smoke.mjs",
"ops:maintenance": "node scripts/ops-maintenance.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" "ci:verify": "npm run typecheck && npm run lint:ci && npm run build"
}, },
"prisma": { "prisma": {

View File

@ -10,6 +10,8 @@
- `APP_URL` (atau `OPS_BASE_URL`) - `APP_URL` (atau `OPS_BASE_URL`)
- `WHATSAPP_WEBHOOK_VERIFY_TOKEN` + `WHATSAPP_WEBHOOK_SECRET` - `WHATSAPP_WEBHOOK_VERIFY_TOKEN` + `WHATSAPP_WEBHOOK_SECRET`
- `CAMPAIGN_RETRY_JOB_TOKEN` - `CAMPAIGN_RETRY_JOB_TOKEN`
- `SESSION_TTL_SECONDS` (opsional, contoh `86400` untuk 24 jam)
- `SESSION_COOKIE_DOMAIN` (opsional untuk cookie shared subdomain)
- optional production hardening: - optional production hardening:
- `HEALTHCHECK_TOKEN` - `HEALTHCHECK_TOKEN`
- `WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR` - `WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR`
@ -24,6 +26,8 @@
- [ ] Run `npm run typecheck`. - [ ] Run `npm run typecheck`.
- [ ] Run `npm run build`. - [ ] Run `npm run build`.
- [ ] Run `npm run ops:smoke` sebelum deploy dan setelah restart service. - [ ] 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-*`). - [ ] Review `.env` tidak mengandung placeholder (contoh `change-me`, `your-*`).
## Runtime Readiness (Post-Deploy) ## Runtime Readiness (Post-Deploy)
@ -35,6 +39,9 @@
- run `npm run ops:maintenance` on a broader schedule (daily/weekly) for cleanup. - run `npm run ops:maintenance` on a broader schedule (daily/weekly) for cleanup.
- [ ] Run ops readiness: `npm run ops:readiness`. - [ ] Run ops readiness: `npm run ops:readiness`.
- [ ] Check endpoint health: `GET /api/health`. - [ ] 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 webhook endpoint reachable: `POST /api/webhooks/whatsapp`.
- [ ] Verify campaign retry endpoint state: - [ ] Verify campaign retry endpoint state:
- `GET /api/jobs/campaign-retry?token=<CAMPAIGN_RETRY_JOB_TOKEN>` - `GET /api/jobs/campaign-retry?token=<CAMPAIGN_RETRY_JOB_TOKEN>`

44
scripts/ops-safe-restart.sh Executable file
View File

@ -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"

View File

@ -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);
});