Compare commits

..

13 Commits

Author SHA1 Message Date
137edc12b7 fix: lates
Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
2026-04-21 20:37:59 +07:00
f48c87e36d Fix tenant creation page error handling and logging
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 20:02:38 +07:00
311551c31a fix(auth): stabilize cookie domain handling behind proxy and add auth debug logs
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 17:36:32 +07:00
c84ce90fcf fix: fallback to signed session payload when DB user row is missing
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:39:05 +07:00
90f794bfe2 fix: validate login redirect target by role
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:34:48 +07:00
7f15725599 fix: adjust login screen height and remove summary cards
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:28:12 +07:00
681e2667e4 fix: respect next param on authenticated /login redirect
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:27:10 +07:00
6c6ed15c31 fix: use forwarded host for auth redirects
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:18:13 +07:00
70183fe23e fix: make login button submit auth form
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:11:41 +07:00
fbaf39e52a fix: replace DATETIME with TIMESTAMP for PostgreSQL migrations
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 12:32:11 +07:00
87180e1858 fix: make initial migration PostgreSQL-compatible
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 12:27:29 +07:00
43f33edc8b chore: switch prisma datasource to postgresql
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 12:21:51 +07:00
3244aeeba9 docs: add concise project context summary
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 10:56:04 +07:00
26 changed files with 1289 additions and 151 deletions

0
.codex Normal file
View File

View File

@ -1,4 +1,5 @@
DATABASE_URL="file:./dev.db" # Prisma datasource: production uses PostgreSQL in this project.
DATABASE_URL="postgresql://whatsapp_inbox:YOUR_DB_PASSWORD@127.0.0.1:5432/whatsapp_inbox?schema=public"
AUTH_SECRET="change-me" AUTH_SECRET="change-me"
WHATSAPP_API_TOKEN="your-meta-token" WHATSAPP_API_TOKEN="your-meta-token"
WHATSAPP_API_VERSION="v22.0" WHATSAPP_API_VERSION="v22.0"
@ -26,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

@ -1,9 +1,20 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE, UserRole, authenticateUser, getDefaultPathForRole, serializeSession } from "@/lib/auth"; import {
SESSION_COOKIE,
SESSION_COOKIE_SECURE_ENV,
getSessionCookieDomain,
getSessionTtlSeconds,
UserRole,
canAccessPath,
authenticateUser,
getDefaultPathForRole,
serializeSession
} from "@/lib/auth";
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getRequestBaseUrl } from "@/lib/request-url";
function getSafePath(value: string | null) { function getSafePath(value: string | null) {
if (!value) { if (!value) {
@ -14,6 +25,10 @@ function getSafePath(value: string | null) {
return null; return null;
} }
if (value.startsWith("//")) {
return null;
}
return value; return value;
} }
@ -26,8 +41,42 @@ function resolveNumber(raw: string | undefined, fallback: number) {
return value; return value;
} }
const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1";
function maskEmail(email: string) {
if (!email) {
return "";
}
const [name, domain] = email.split("@");
if (!domain) {
return "*****";
}
if (name.length <= 2) {
return `${name[0]}***@${domain}`;
}
return `${name.slice(0, 2)}***@${domain}`;
}
function shouldUseSecureCookies(request: NextRequest) {
const explicit = SESSION_COOKIE_SECURE_ENV;
if (explicit === "true" || explicit === "1") {
return true;
}
if (explicit === "false" || explicit === "0") {
return false;
}
const forwardedProto = request.headers.get("x-forwarded-proto");
return request.nextUrl.protocol === "https:" || (forwardedProto?.split(",")[0]?.toLowerCase() === "https");
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const { ipAddress, userAgent } = await getRequestAuditContext(); const { ipAddress, userAgent } = await getRequestAuditContext();
const baseUrl = getRequestBaseUrl(request);
const retryControl = consumeRateLimit(ipAddress || "unknown", { const retryControl = consumeRateLimit(ipAddress || "unknown", {
scope: "auth_login", scope: "auth_login",
limit: resolveNumber(process.env.LOGIN_RATE_LIMIT_ATTEMPTS, 10), limit: resolveNumber(process.env.LOGIN_RATE_LIMIT_ATTEMPTS, 10),
@ -35,7 +84,7 @@ export async function POST(request: NextRequest) {
}); });
if (!retryControl.allowed) { if (!retryControl.allowed) {
const loginUrl = new URL("/login", request.url); const loginUrl = new URL("/login", baseUrl);
loginUrl.searchParams.set("error", "rate_limited"); loginUrl.searchParams.set("error", "rate_limited");
const response = NextResponse.redirect(loginUrl); const response = NextResponse.redirect(loginUrl);
const headers = getRateLimitHeaders(retryControl); const headers = getRateLimitHeaders(retryControl);
@ -53,9 +102,18 @@ export async function POST(request: NextRequest) {
const next = getSafePath(typeof rawNext === "string" ? rawNext : null); const next = getSafePath(typeof rawNext === "string" ? rawNext : null);
const email = typeof rawEmail === "string" ? rawEmail.trim() : ""; const email = typeof rawEmail === "string" ? rawEmail.trim() : "";
const password = typeof rawPassword === "string" ? rawPassword : ""; const password = typeof rawPassword === "string" ? rawPassword : "";
if (AUTH_DEBUG) {
console.warn("[AUTH] login_attempt", {
email: maskEmail(email),
hasPassword: password.length > 0,
next,
ipAddress,
userAgent
});
}
if (!email || !password) { if (!email || !password) {
const loginUrl = new URL("/login", request.url); const loginUrl = new URL("/login", baseUrl);
loginUrl.searchParams.set("error", "credentials_required"); loginUrl.searchParams.set("error", "credentials_required");
if (next) { if (next) {
loginUrl.searchParams.set("next", next); loginUrl.searchParams.set("next", next);
@ -88,12 +146,20 @@ export async function POST(request: NextRequest) {
}); });
} }
const loginUrl = new URL("/login", request.url); const loginUrl = new URL("/login", baseUrl);
loginUrl.searchParams.set("error", "invalid_credentials"); loginUrl.searchParams.set("error", "invalid_credentials");
if (next) { if (next) {
loginUrl.searchParams.set("next", next); loginUrl.searchParams.set("next", next);
} }
if (AUTH_DEBUG) {
console.warn("[AUTH] login_failed", {
email: maskEmail(email),
ipAddress,
userAgent
});
}
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
@ -116,14 +182,48 @@ export async function POST(request: NextRequest) {
}); });
const destination = next ?? getDefaultPathForRole(session.role as UserRole); const destination = next ?? getDefaultPathForRole(session.role as UserRole);
const response = NextResponse.redirect(new URL(destination, request.url)); const safeDestination =
destination && canAccessPath(session.role as UserRole, destination)
? destination
: getDefaultPathForRole(session.role as UserRole);
const sessionMaxAgeSeconds = Math.max(
60,
Math.floor(session.expiresAt - Math.floor(Date.now() / 1000))
);
const response = NextResponse.redirect(new URL(safeDestination, baseUrl));
response.cookies.set(SESSION_COOKIE, await serializeSession(session), { response.cookies.set(SESSION_COOKIE, await serializeSession(session), {
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: shouldUseSecureCookies(request),
path: "/", path: "/",
maxAge: Math.max(1, Math.floor(session.expiresAt - Date.now() / 1000)) domain: getSessionCookieDomain(),
maxAge: sessionMaxAgeSeconds
}); });
if (AUTH_DEBUG) {
console.warn("[AUTH] session_cookie_issued", {
userId: session.userId,
role: session.role,
sessionExpiresAt: session.expiresAt,
sessionMaxAgeFromEnv: getSessionTtlSeconds(),
maxAge: sessionMaxAgeSeconds,
host: request.headers.get("host") || "unknown",
protocol: request.nextUrl.protocol,
forwardedProto: request.headers.get("x-forwarded-proto") || "unknown",
secureCookies: shouldUseSecureCookies(request)
});
console.warn("[AUTH] login_success_redirect", {
userId: session.userId,
destination: safeDestination,
setCookie: response.headers.get("set-cookie")
});
}
response.headers.set("X-Auth-Session", "issued");
response.headers.set("X-Auth-Session-User", session.userId);
response.headers.set("X-Auth-Session-Role", session.role);
response.headers.set("X-Auth-Session-Base-Url", baseUrl.toString());
response.headers.set("X-Auth-Session-Max-Age", String(sessionMaxAgeSeconds));
response.headers.set("X-Auth-Session-Secure", String(shouldUseSecureCookies(request)));
return response; return response;
} }

View File

@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; 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";
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();
@ -20,7 +21,13 @@ export async function GET(request: NextRequest) {
}); });
} }
const response = NextResponse.redirect(new URL("/login", request.url)); const response = NextResponse.redirect(new URL("/login", getRequestBaseUrl(request)));
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

@ -27,9 +27,9 @@ export default async function LoginPage({
: null; : null;
return ( return (
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-14"> <main className="flex min-h-screen items-center justify-center bg-background px-4 py-6 sm:px-6 sm:py-8">
<div className="grid w-full max-w-5xl overflow-hidden rounded-[2rem] bg-surface-container-lowest shadow-floating"> <div className="grid w-full max-w-5xl overflow-hidden rounded-[2rem] bg-surface-container-lowest shadow-floating md:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<section className="bg-surface-container-lowest border-b border-line px-10 py-16 text-center md:border-r md:border-b-0 md:px-14 md:py-20"> <section className="border-b border-line bg-surface-container-lowest px-8 py-10 text-center md:flex md:flex-col md:justify-center md:border-r md:border-b-0 md:px-12 md:py-12">
<Image <Image
src="/logo_zappcare.png" src="/logo_zappcare.png"
alt="ZappCare" alt="ZappCare"
@ -38,22 +38,12 @@ export default async function LoginPage({
className="mx-auto h-14 w-auto rounded-full" className="mx-auto h-14 w-auto rounded-full"
priority priority
/> />
<h1 className="mt-8 text-4xl font-extrabold font-headline text-on-surface">{t("login", "title")}</h1> <h1 className="mt-6 text-3xl font-extrabold font-headline text-on-surface sm:text-4xl">{t("login", "title")}</h1>
<p className="mx-auto mt-3 max-w-sm text-sm text-on-surface-variant"> <p className="mx-auto mt-3 max-w-sm text-sm text-on-surface-variant">
{t("login", "signin_subtitle")} {t("login", "signin_subtitle")}
</p> </p>
<div className="mx-auto mt-10 flex w-full max-w-sm flex-col gap-3">
<div className="rounded-[1.5rem] bg-surface-container-low p-4">
<p className="text-2xl font-black text-on-surface">3</p>
<p className="mt-1 text-sm text-on-surface-variant">Role aktif saat ini</p>
</div>
<div className="rounded-[1.5rem] bg-surface-container-low p-4">
<p className="text-2xl font-black text-on-surface">10+</p>
<p className="mt-1 text-sm text-on-surface-variant">Modul operasi aktif</p>
</div>
</div>
</section> </section>
<section className="px-8 py-10 md:px-12 md:py-16"> <section className="px-6 py-8 sm:px-8 sm:py-10 md:flex md:flex-col md:justify-center md:px-12 md:py-12">
<div className="mx-auto max-w-md"> <div className="mx-auto max-w-md">
<p className="text-sm font-black uppercase tracking-[0.22em] text-primary">{t("login", "signin_label")}</p> <p className="text-sm font-black uppercase tracking-[0.22em] text-primary">{t("login", "signin_label")}</p>
<h2 className="mt-3 text-3xl font-black font-headline text-on-surface">{t("login", "signin_subtitle")}</h2> <h2 className="mt-3 text-3xl font-black font-headline text-on-surface">{t("login", "signin_subtitle")}</h2>
@ -96,7 +86,7 @@ export default async function LoginPage({
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline">visibility</span> <span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline">visibility</span>
</div> </div>
</label> </label>
<Button className="w-full">{t("login", "sign_in_button")}</Button> <Button type="submit" className="w-full">{t("login", "sign_in_button")}</Button>
<button <button
type="button" type="button"
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-line bg-surface-container-low px-4 py-2 text-sm font-semibold text-on-surface transition hover:bg-surface-container-high" className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-line bg-surface-container-low px-4 py-2 text-sm font-semibold text-on-surface transition hover:bg-surface-container-high"

View File

@ -15,10 +15,22 @@ export default async function NewTenantPage({
redirect("/unauthorized"); redirect("/unauthorized");
} }
const [plans, params] = await Promise.all([ const params = await (searchParams ?? Promise.resolve({ error: undefined }));
prisma.subscriptionPlan.findMany({ orderBy: { priceMonthly: "asc" } }), let plans: Array<{
searchParams ?? Promise.resolve({ error: undefined }) id: string;
]); name: string;
code: string;
priceMonthly: number;
}> = [];
try {
plans = await prisma.subscriptionPlan.findMany({ orderBy: { priceMonthly: "asc" } });
} catch (error) {
console.error("[tenant/new] load plans failed", {
actorUserId: session.userId,
error: error instanceof Error ? error.message : String(error)
});
}
const error = params.error; const error = params.error;
const errorMessage = const errorMessage =
@ -30,6 +42,10 @@ export default async function NewTenantPage({
? "Slug tenant sudah dipakai." ? "Slug tenant sudah dipakai."
: error === "admin_email_exists" : error === "admin_email_exists"
? "Email admin awal sudah terpakai." ? "Email admin awal sudah terpakai."
: error === "tenant_creation_failed"
? "Tidak bisa membuat tenant, silakan cek log server untuk detail error."
: error === "plans_fetch_failed"
? "Tidak dapat memuat daftar plan. Cek log server/DB koneksi."
: null; : null;
return ( return (
@ -37,6 +53,11 @@ export default async function NewTenantPage({
<SectionCard title="Tenant form"> <SectionCard title="Tenant form">
<form action={createTenant} className="grid gap-4 md:max-w-3xl md:grid-cols-2"> <form action={createTenant} className="grid gap-4 md:max-w-3xl md:grid-cols-2">
{errorMessage ? <p className="col-span-2 rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null} {errorMessage ? <p className="col-span-2 rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null}
{plans.length === 0 ? (
<p className="col-span-2 rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">
Belum ada data plan. Isi dulu plan pada <a href="/super-admin/billing/plans" className="underline">Catalog Plan</a> sebelum membuat tenant.
</p>
) : null}
<input name="name" className="rounded-xl border border-line px-4 py-3" placeholder="Company name" required /> <input name="name" className="rounded-xl border border-line px-4 py-3" placeholder="Company name" required />
<input name="slug" className="rounded-xl border border-line px-4 py-3" placeholder="Tenant slug" required /> <input name="slug" className="rounded-xl border border-line px-4 py-3" placeholder="Tenant slug" required />
<input name="timezone" className="rounded-xl border border-line px-4 py-3" placeholder="Timezone" required /> <input name="timezone" className="rounded-xl border border-line px-4 py-3" placeholder="Timezone" required />

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>{t("nav", "logout")}</span> <span className="material-symbols-outlined text-sm">logout</span>
</Link> <span>{t("nav", "logout")}</span>
</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">

58
context.txt Normal file
View File

@ -0,0 +1,58 @@
# Ringkasan Pekerjaan (WhatsApp Inbox Platform)
Periode: 2026-04-20 s.d. 2026-04-21
## 1) Inisialisasi proyek
- Rename folder kerja jadi `whatsapp-inbox-platform`.
- Inisiasi Git lokal dan set remote:
- `https://git.iptek.co/wirabasalamah/whatsapp-inbox-platform.git`
- Membuat commit awal proyek:
- `adde003` — `chore: initial project import`
## 2) Fitur inti yang sudah dibangun (singkat)
- Pembuatan struktur screen & UI mengikuti `screen_design` sesuai logo `logo_zappcare.png`.
- Implementasi tema strict mode.
- Realisasi koneksi data nyata dengan Prisma/DB, migration, seed, dan koneksi halaman inti.
- Integrasi multi-bahasa (ID/EN) via i18n.
- Validasi role/action dan role-permission.
- Implementasi retry campaign (manual + background daemon), audit trail terstruktur, dan event sync (webhook + send adapter).
- Retry/backoff + sinkronisasi event + alerting.
- Peningkatan kesiapan production (safety/ops readiness, smoke, runbook).
## 3) Deployment & operasi
- Menyiapkan panduan deployment lengkap Ubuntu:
- `INSTALL-UBUNTU-APP-ZAPPCARE-FROM-GITEA.md`
- `INSTALL-UBUNTU-APP-ZAPPCARE.md`
- Deployment dirancang port `3002` (menghindari konflik dengan `3000` dan `3001`).
- Tambah systemd service:
- `whatsapp-inbox` (app)
- `whatsapp-inbox-retry` (daemon retry campaign)
- Konfigurasi Nginx + SSL LetsEncrypt untuk `app.zappcare.id`.
- Menyiapkan instruksi pull/rollback/update rutin.
## 4) Repo hygiene
- Menambahkan aturan `.gitignore` agar aman dari file lokal:
- `.env`
- `prisma/*.db`, `prisma/*.db-journal`
- `.DS_Store`
- (sudah ada sebelumnya untuk `.next`, `node_modules`, `dev.db`, `dev.db-journal`)
- Commit housekeeping:
- `1abd899` — `chore: ignore local env/db artifacts`
- Membersihkan working tree dari artifact lokal yang tidak di-track.
## 5) State saat ini
- Branch: `main`
- Commit terakhir:
- `1abd899`
- Untracked sensitive file sudah dibersihkan di workspace, dan pattern sudah di-ignore agar tidak masuk repo.
- File yang sudah menjadi acuan utama di repo:
- `context.txt` (ringkasan ini)
- `INSTALL-UBUNTU-APP-ZAPPCARE-FROM-GIT.md` (sumber Git deployment)
- `INSTALL-UBUNTU-APP-ZAPPCARE.md` dan `ops-runbook.md` (ops guide)
## 6) Langkah berikutnya (opsional)
- Verifikasi akhir:
- `git status`
- `git push -u origin main`
- Atur akses token Gitea (PAT) untuk push otomatis agar tidak pakai password.
- Jalankan smoke/readiness di server staging/production.

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

@ -1153,13 +1153,13 @@ export async function createTenant(formData: FormData) {
const session = await requireSuperAdminSession(); const session = await requireSuperAdminSession();
const auditContext = await getAuditContext(session); const auditContext = await getAuditContext(session);
const name = formValue(formData, "name"); const name = formValue(formData, "name").trim();
const companyName = formValue(formData, "companyName") || name; const companyName = (formValue(formData, "companyName") || name).trim();
const slug = formValue(formData, "slug").toLowerCase(); const slug = formValue(formData, "slug").trim().toLowerCase();
const timezone = formValue(formData, "timezone"); const timezone = formValue(formData, "timezone").trim();
const planId = formValue(formData, "planId"); const planId = formValue(formData, "planId");
const adminFullName = formValue(formData, "adminFullName") || `Admin ${name}`; const adminFullName = (formValue(formData, "adminFullName") || `Admin ${name}`).trim();
const adminEmail = formValue(formData, "adminEmail"); const adminEmail = formValue(formData, "adminEmail").trim() || undefined;
const adminPassword = formValue(formData, "adminPassword"); const adminPassword = formValue(formData, "adminPassword");
if (!name || !slug || !timezone || !planId) { if (!name || !slug || !timezone || !planId) {
@ -1191,17 +1191,33 @@ export async function createTenant(formData: FormData) {
adminEmail: string; adminEmail: string;
}; };
const tenantCreation = await prisma.$transaction(async (tx) => { let tenantCreation: {
const createdTenant = await tx.tenant.create({ tenant: {
data: { id: string;
name, name: string;
companyName: companyName || name, slug: string;
slug, planId: string;
timezone, timezone: string;
status: TenantStatus.ACTIVE, status: TenantStatus;
planId createdAt: Date;
} updatedAt: Date;
}); companyName: string;
};
adminInvite: AdminInvitePayload | null;
};
try {
tenantCreation = await prisma.$transaction(async (tx) => {
const createdTenant = await tx.tenant.create({
data: {
name,
companyName: companyName || name,
slug,
timezone,
status: TenantStatus.ACTIVE,
planId
}
});
if (adminEmail) { if (adminEmail) {
const shouldInviteAdmin = !adminPassword; const shouldInviteAdmin = !adminPassword;
@ -1243,11 +1259,21 @@ export async function createTenant(formData: FormData) {
} }
} }
return { return {
tenant: createdTenant, tenant: createdTenant,
adminInvite: null adminInvite: null
}; };
}); });
} catch (error) {
console.error("[createTenant] transaction failed", {
actorUserId: session.userId,
tenantName: name,
tenantSlug: slug,
planId,
error: error instanceof Error ? error.message : String(error)
});
redirect("/super-admin/tenants/new?error=tenant_creation_failed");
}
const tenant = tenantCreation.tenant; const tenant = tenantCreation.tenant;
const adminInvite = tenantCreation.adminInvite; const adminInvite = tenantCreation.adminInvite;

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;
@ -238,8 +277,8 @@ export async function parseSessionCookie(raw: string) {
return { return {
userId, userId,
role: role as UserRole, role: role as UserRole,
tenantId, tenantId,
tenantName: "", tenantName: "",
fullName: "", fullName: "",
email: "", email: "",
@ -269,7 +308,17 @@ export async function getSession() {
}); });
if (!user) { if (!user) {
return null; return {
userId: parsed.userId,
fullName: "User",
email: "",
role: parsed.role,
tenantId: parsed.tenantId,
tenantName: parsed.tenantId,
extraPermissions: [],
issuedAt: parsed.issuedAt,
expiresAt: parsed.expiresAt
};
} }
return { return {

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()

19
lib/request-url.ts Normal file
View File

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
export function getRequestBaseUrl(request: NextRequest) {
const configured = process.env.APP_URL?.trim();
if (configured) {
return new URL(configured);
}
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto");
const host = forwardedHost?.split(",")[0]?.trim() || request.headers.get("host") || request.nextUrl.host;
const proto = (forwardedProto?.split(",")[0]?.trim() || request.nextUrl.protocol || "http").replace(":", "");
if (!host) {
return request.nextUrl;
}
return new URL(`${proto}://${host}`);
}

View File

@ -2,6 +2,10 @@ 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 { getSessionCookieDomain } from "@/lib/auth";
const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1";
const publicPaths = ["/login", "/forgot-password", "/reset-password", "/unauthorized", "/invite", "/auth"]; const publicPaths = ["/login", "/forgot-password", "/reset-password", "/unauthorized", "/invite", "/auth"];
@ -9,12 +13,52 @@ function isPublicPath(pathname: string) {
return publicPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)); return publicPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`));
} }
function shouldUseSecureCookies(request: NextRequest) {
const explicit = process.env.COOKIE_SECURE?.toLowerCase() ?? "";
if (explicit === "true" || explicit === "1") {
return true;
}
if (explicit === "false" || explicit === "0") {
return false;
}
const forwardedProto = request.headers.get("x-forwarded-proto");
return request.nextUrl.protocol === "https:" || (forwardedProto?.split(",")[0]?.toLowerCase() === "https");
}
function debugAuth(message: string, details: Record<string, unknown> = {}) {
if (!AUTH_DEBUG) {
return;
}
console.warn(`[AUTH] ${message}`, details);
}
function setDebugHeaders(response: NextResponse, headers: Record<string, string>) {
if (!AUTH_DEBUG) {
return;
}
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
}
async function decodeSessionCookie(value: string) { async function decodeSessionCookie(value: string) {
return (await parseSessionCookie(value)) as null | { role: UserRole }; const parsed = (await parseSessionCookie(value)) as null | { role: UserRole };
if (!parsed) {
debugAuth("invalid_session_cookie", {
cookieLength: value.length,
cookiePreview: `${value.slice(0, 28)}...${value.slice(-14)}`
});
}
return parsed;
} }
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const baseUrl = getRequestBaseUrl(request);
const response = NextResponse.next(); const response = NextResponse.next();
if (pathname.startsWith("/_next") || pathname.includes(".")) { if (pathname.startsWith("/_next") || pathname.includes(".")) {
@ -30,33 +74,86 @@ 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: process.env.NODE_ENV === "production", secure: shouldUseSecureCookies(request),
sameSite: "lax" sameSite: "lax"
}); });
} 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: process.env.NODE_ENV === "production", secure: shouldUseSecureCookies(request),
sameSite: "lax" sameSite: "lax"
}); });
} }
if (!session && !isPublicPath(pathname) && pathname !== "/") { if (!session && !isPublicPath(pathname) && pathname !== "/") {
const loginUrl = new URL("/login", request.url); const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unknown";
debugAuth("missing_or_invalid_session", {
pathname,
method: request.method,
ip: clientIp,
userAgent: request.headers.get("user-agent") || "unknown",
hasSessionCookie: Boolean(sessionCookie),
sessionCookieLength: sessionCookie?.length || 0,
host: request.headers.get("host") || "unknown",
protocol: request.nextUrl.protocol,
forwardedProto: request.headers.get("x-forwarded-proto") || "unknown",
secureCookies: shouldUseSecureCookies(request)
});
const loginUrl = new URL("/login", baseUrl);
loginUrl.searchParams.set("next", pathname); loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
if (session && (pathname === "/" || pathname === "/login")) { if (session && (pathname === "/" || pathname === "/login")) {
return NextResponse.redirect(new URL(getDefaultPathForRole(session.role), request.url)); const requested = request.nextUrl.searchParams.get("next");
const hasSafeNext = typeof requested === "string" && requested.startsWith("/") && !requested.startsWith("//");
const nextPath = hasSafeNext ? requested : null;
const destination = nextPath && canAccessPath(session.role, nextPath) ? nextPath : getDefaultPathForRole(session.role);
const redirectResponse = NextResponse.redirect(new URL(destination, baseUrl));
setDebugHeaders(redirectResponse, {
"X-Auth-Session": "valid",
"X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)),
"X-Auth-Session-Role": session.role,
"X-Auth-Path": pathname,
"X-Auth-Base-Url": baseUrl.toString()
});
return redirectResponse;
} }
if (session && !isPublicPath(pathname) && !canAccessPath(session.role, pathname)) { if (session && !isPublicPath(pathname) && !canAccessPath(session.role, pathname)) {
return NextResponse.redirect(new URL("/unauthorized", request.url)); debugAuth("role_forbidden", {
pathname,
role: session.role
});
const forbiddenResponse = NextResponse.redirect(new URL("/unauthorized", baseUrl));
setDebugHeaders(forbiddenResponse, {
"X-Auth-Session": "forbidden",
"X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)),
"X-Auth-Session-Role": session.role,
"X-Auth-Path": pathname,
"X-Auth-Base-Url": baseUrl.toString()
});
return forbiddenResponse;
} }
setDebugHeaders(response, {
"X-Auth-Session": session ? "valid" : "missing",
"X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)),
"X-Auth-Session-Valid-Role": session?.role || "n/a",
"X-Auth-Path": pathname,
"X-Auth-Base-Url": baseUrl.toString(),
"X-Auth-Host": request.headers.get("host") || "unknown"
});
return response; return response;
} }

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

@ -8,8 +8,8 @@ CREATE TABLE "SubscriptionPlan" (
"seatQuota" INTEGER NOT NULL, "seatQuota" INTEGER NOT NULL,
"broadcastQuota" INTEGER NOT NULL, "broadcastQuota" INTEGER NOT NULL,
"featuresJson" JSONB, "featuresJson" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL "updatedAt" TIMESTAMP(3) NOT NULL
); );
-- CreateTable -- CreateTable
@ -21,8 +21,8 @@ CREATE TABLE "Tenant" (
"timezone" TEXT NOT NULL, "timezone" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'TRIAL', "status" TEXT NOT NULL DEFAULT 'TRIAL',
"planId" TEXT NOT NULL, "planId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_planId_fkey" FOREIGN KEY ("planId") REFERENCES "SubscriptionPlan" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "Tenant_planId_fkey" FOREIGN KEY ("planId") REFERENCES "SubscriptionPlan" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -33,8 +33,8 @@ CREATE TABLE "Role" (
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"code" TEXT NOT NULL, "code" TEXT NOT NULL,
"permissionsJson" JSONB, "permissionsJson" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Role_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE SET NULL ON UPDATE CASCADE CONSTRAINT "Role_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE SET NULL ON UPDATE CASCADE
); );
@ -48,9 +48,9 @@ CREATE TABLE "User" (
"roleId" TEXT NOT NULL, "roleId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'INVITED', "status" TEXT NOT NULL DEFAULT 'INVITED',
"avatarUrl" TEXT, "avatarUrl" TEXT,
"lastLoginAt" DATETIME, "lastLoginAt" TIMESTAMP(3),
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -66,9 +66,9 @@ CREATE TABLE "Channel" (
"displayPhoneNumber" TEXT, "displayPhoneNumber" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING', "status" TEXT NOT NULL DEFAULT 'PENDING',
"webhookStatus" TEXT, "webhookStatus" TEXT,
"lastSyncAt" DATETIME, "lastSyncAt" TIMESTAMP(3),
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Channel_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "Channel_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -84,9 +84,9 @@ CREATE TABLE "Contact" (
"avatarUrl" TEXT, "avatarUrl" TEXT,
"countryCode" TEXT, "countryCode" TEXT,
"optInStatus" TEXT NOT NULL DEFAULT 'UNKNOWN', "optInStatus" TEXT NOT NULL DEFAULT 'UNKNOWN',
"lastInteractionAt" DATETIME, "lastInteractionAt" TIMESTAMP(3),
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Contact_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Contact_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Contact_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE SET NULL ON UPDATE CASCADE CONSTRAINT "Contact_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE SET NULL ON UPDATE CASCADE
); );
@ -97,8 +97,8 @@ CREATE TABLE "Tag" (
"tenantId" TEXT NOT NULL, "tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"color" TEXT, "color" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tag_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "Tag_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -108,7 +108,7 @@ CREATE TABLE "ContactTag" (
"tenantId" TEXT NOT NULL, "tenantId" TEXT NOT NULL,
"contactId" TEXT NOT NULL, "contactId" TEXT NOT NULL,
"tagId" TEXT NOT NULL, "tagId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContactTag_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "ContactTag_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ContactTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "ContactTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -123,13 +123,13 @@ CREATE TABLE "Conversation" (
"status" TEXT NOT NULL DEFAULT 'OPEN', "status" TEXT NOT NULL DEFAULT 'OPEN',
"priority" TEXT NOT NULL DEFAULT 'NORMAL', "priority" TEXT NOT NULL DEFAULT 'NORMAL',
"assignedUserId" TEXT, "assignedUserId" TEXT,
"firstMessageAt" DATETIME, "firstMessageAt" TIMESTAMP(3),
"lastMessageAt" DATETIME, "lastMessageAt" TIMESTAMP(3),
"lastInboundAt" DATETIME, "lastInboundAt" TIMESTAMP(3),
"lastOutboundAt" DATETIME, "lastOutboundAt" TIMESTAMP(3),
"resolvedAt" DATETIME, "resolvedAt" TIMESTAMP(3),
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Conversation_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Conversation_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Conversation_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Conversation_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Conversation_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Conversation_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
@ -153,10 +153,10 @@ CREATE TABLE "Message" (
"deliveryStatus" TEXT NOT NULL DEFAULT 'QUEUED', "deliveryStatus" TEXT NOT NULL DEFAULT 'QUEUED',
"failedReason" TEXT, "failedReason" TEXT,
"sentByUserId" TEXT, "sentByUserId" TEXT,
"sentAt" DATETIME, "sentAt" TIMESTAMP(3),
"deliveredAt" DATETIME, "deliveredAt" TIMESTAMP(3),
"readAt" DATETIME, "readAt" TIMESTAMP(3),
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Message_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Message_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Message_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Message_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
@ -171,8 +171,8 @@ CREATE TABLE "ConversationNote" (
"conversationId" TEXT NOT NULL, "conversationId" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"content" TEXT NOT NULL, "content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConversationNote_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "ConversationNote_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ConversationNote_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "ConversationNote_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ConversationNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "ConversationNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
@ -184,7 +184,7 @@ CREATE TABLE "ConversationTag" (
"tenantId" TEXT NOT NULL, "tenantId" TEXT NOT NULL,
"conversationId" TEXT NOT NULL, "conversationId" TEXT NOT NULL,
"tagId" TEXT NOT NULL, "tagId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConversationTag_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "ConversationTag_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ConversationTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "ConversationTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -197,7 +197,7 @@ CREATE TABLE "ConversationActivity" (
"actorUserId" TEXT, "actorUserId" TEXT,
"activityType" TEXT NOT NULL, "activityType" TEXT NOT NULL,
"metadataJson" JSONB, "metadataJson" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConversationActivity_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "ConversationActivity_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ConversationActivity_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "ConversationActivity_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ConversationActivity_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE CONSTRAINT "ConversationActivity_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
@ -210,8 +210,8 @@ CREATE TABLE "ContactSegment" (
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"description" TEXT, "description" TEXT,
"rulesJson" JSONB, "rulesJson" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContactSegment_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "ContactSegment_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -221,7 +221,7 @@ CREATE TABLE "SegmentMember" (
"tenantId" TEXT NOT NULL, "tenantId" TEXT NOT NULL,
"segmentId" TEXT NOT NULL, "segmentId" TEXT NOT NULL,
"contactId" TEXT NOT NULL, "contactId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentMember_segmentId_fkey" FOREIGN KEY ("segmentId") REFERENCES "ContactSegment" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "SegmentMember_segmentId_fkey" FOREIGN KEY ("segmentId") REFERENCES "ContactSegment" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "SegmentMember_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "SegmentMember_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -242,8 +242,8 @@ CREATE TABLE "MessageTemplate" (
"providerTemplateId" TEXT, "providerTemplateId" TEXT,
"approvalStatus" TEXT NOT NULL DEFAULT 'DRAFT', "approvalStatus" TEXT NOT NULL DEFAULT 'DRAFT',
"rejectedReason" TEXT, "rejectedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MessageTemplate_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "MessageTemplate_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "MessageTemplate_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "MessageTemplate_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -259,17 +259,17 @@ CREATE TABLE "BroadcastCampaign" (
"campaignType" TEXT NOT NULL DEFAULT 'BROADCAST', "campaignType" TEXT NOT NULL DEFAULT 'BROADCAST',
"audienceType" TEXT NOT NULL, "audienceType" TEXT NOT NULL,
"segmentId" TEXT, "segmentId" TEXT,
"scheduledAt" DATETIME, "scheduledAt" TIMESTAMP(3),
"startedAt" DATETIME, "startedAt" TIMESTAMP(3),
"finishedAt" DATETIME, "finishedAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'DRAFT', "status" TEXT NOT NULL DEFAULT 'DRAFT',
"totalRecipients" INTEGER NOT NULL DEFAULT 0, "totalRecipients" INTEGER NOT NULL DEFAULT 0,
"totalSent" INTEGER NOT NULL DEFAULT 0, "totalSent" INTEGER NOT NULL DEFAULT 0,
"totalDelivered" INTEGER NOT NULL DEFAULT 0, "totalDelivered" INTEGER NOT NULL DEFAULT 0,
"totalRead" INTEGER NOT NULL DEFAULT 0, "totalRead" INTEGER NOT NULL DEFAULT 0,
"totalFailed" INTEGER NOT NULL DEFAULT 0, "totalFailed" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BroadcastCampaign_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "BroadcastCampaign_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BroadcastCampaign_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "BroadcastCampaign_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BroadcastCampaign_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "MessageTemplate" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "BroadcastCampaign_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "MessageTemplate" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
@ -287,10 +287,10 @@ CREATE TABLE "CampaignRecipient" (
"sendStatus" TEXT NOT NULL DEFAULT 'QUEUED', "sendStatus" TEXT NOT NULL DEFAULT 'QUEUED',
"failureReason" TEXT, "failureReason" TEXT,
"providerMessageId" TEXT, "providerMessageId" TEXT,
"sentAt" DATETIME, "sentAt" TIMESTAMP(3),
"deliveredAt" DATETIME, "deliveredAt" TIMESTAMP(3),
"readAt" DATETIME, "readAt" TIMESTAMP(3),
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CampaignRecipient_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "BroadcastCampaign" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "CampaignRecipient_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "BroadcastCampaign" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "CampaignRecipient_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "CampaignRecipient_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -306,7 +306,7 @@ CREATE TABLE "AuditLog" (
"metadataJson" JSONB, "metadataJson" JSONB,
"ipAddress" TEXT, "ipAddress" TEXT,
"userAgent" TEXT, "userAgent" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "AuditLog_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
); );
@ -321,8 +321,8 @@ CREATE TABLE "WebhookEvent" (
"payloadJson" JSONB NOT NULL, "payloadJson" JSONB NOT NULL,
"processStatus" TEXT NOT NULL, "processStatus" TEXT NOT NULL,
"failedReason" TEXT, "failedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" DATETIME, "processedAt" TIMESTAMP(3),
CONSTRAINT "WebhookEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "WebhookEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WebhookEvent_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE SET NULL ON UPDATE CASCADE CONSTRAINT "WebhookEvent_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel" ("id") ON DELETE SET NULL ON UPDATE CASCADE
); );
@ -331,15 +331,15 @@ CREATE TABLE "WebhookEvent" (
CREATE TABLE "UsageMetric" ( CREATE TABLE "UsageMetric" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL, "tenantId" TEXT NOT NULL,
"metricDate" DATETIME NOT NULL, "metricDate" TIMESTAMP(3) NOT NULL,
"inboundMessages" INTEGER NOT NULL DEFAULT 0, "inboundMessages" INTEGER NOT NULL DEFAULT 0,
"outboundMessages" INTEGER NOT NULL DEFAULT 0, "outboundMessages" INTEGER NOT NULL DEFAULT 0,
"activeContacts" INTEGER NOT NULL DEFAULT 0, "activeContacts" INTEGER NOT NULL DEFAULT 0,
"activeAgents" INTEGER NOT NULL DEFAULT 0, "activeAgents" INTEGER NOT NULL DEFAULT 0,
"broadcastSent" INTEGER NOT NULL DEFAULT 0, "broadcastSent" INTEGER NOT NULL DEFAULT 0,
"storageUsedMb" INTEGER NOT NULL DEFAULT 0, "storageUsedMb" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UsageMetric_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "UsageMetric_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );
@ -349,16 +349,16 @@ CREATE TABLE "BillingInvoice" (
"tenantId" TEXT NOT NULL, "tenantId" TEXT NOT NULL,
"planId" TEXT NOT NULL, "planId" TEXT NOT NULL,
"invoiceNumber" TEXT NOT NULL, "invoiceNumber" TEXT NOT NULL,
"periodStart" DATETIME NOT NULL, "periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" DATETIME NOT NULL, "periodEnd" TIMESTAMP(3) NOT NULL,
"subtotal" DECIMAL NOT NULL, "subtotal" DECIMAL NOT NULL,
"taxAmount" DECIMAL NOT NULL, "taxAmount" DECIMAL NOT NULL,
"totalAmount" DECIMAL NOT NULL, "totalAmount" DECIMAL NOT NULL,
"paymentStatus" TEXT NOT NULL DEFAULT 'UNPAID', "paymentStatus" TEXT NOT NULL DEFAULT 'UNPAID',
"dueDate" DATETIME NOT NULL, "dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" DATETIME, "paidAt" TIMESTAMP(3),
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BillingInvoice_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "BillingInvoice_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BillingInvoice_planId_fkey" FOREIGN KEY ("planId") REFERENCES "SubscriptionPlan" ("id") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "BillingInvoice_planId_fkey" FOREIGN KEY ("planId") REFERENCES "SubscriptionPlan" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
); );

View File

@ -1,4 +1,4 @@
ALTER TABLE "CampaignRecipient" ADD COLUMN "sendAttempts" INTEGER NOT NULL DEFAULT 0; ALTER TABLE "CampaignRecipient" ADD COLUMN "sendAttempts" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "CampaignRecipient" ADD COLUMN "maxSendAttempts" INTEGER NOT NULL DEFAULT 3; ALTER TABLE "CampaignRecipient" ADD COLUMN "maxSendAttempts" INTEGER NOT NULL DEFAULT 3;
ALTER TABLE "CampaignRecipient" ADD COLUMN "lastAttemptAt" DATETIME; ALTER TABLE "CampaignRecipient" ADD COLUMN "lastAttemptAt" TIMESTAMP(3);
ALTER TABLE "CampaignRecipient" ADD COLUMN "nextRetryAt" DATETIME; ALTER TABLE "CampaignRecipient" ADD COLUMN "nextRetryAt" TIMESTAMP(3);

View File

@ -2,15 +2,15 @@ CREATE TABLE "BackgroundJobState" (
"id" TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
"jobName" TEXT NOT NULL, "jobName" TEXT NOT NULL,
"lockedBy" TEXT NOT NULL, "lockedBy" TEXT NOT NULL,
"lockedUntil" DATETIME, "lockedUntil" TIMESTAMP(3),
"runs" INTEGER NOT NULL DEFAULT 0, "runs" INTEGER NOT NULL DEFAULT 0,
"lastRunStartedAt" DATETIME, "lastRunStartedAt" TIMESTAMP(3),
"lastRunCompletedAt" DATETIME, "lastRunCompletedAt" TIMESTAMP(3),
"lastRunStatus" TEXT, "lastRunStatus" TEXT,
"lastRunSummaryJson" JSONB, "lastRunSummaryJson" JSONB,
"lastError" TEXT, "lastError" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BackgroundJobState_jobName_key" UNIQUE ("jobName") CONSTRAINT "BackgroundJobState_jobName_key" UNIQUE ("jobName")
); );

View File

@ -1,2 +1,2 @@
ALTER TABLE "BackgroundJobState" ADD COLUMN "consecutiveFailures" INTEGER NOT NULL DEFAULT 0; ALTER TABLE "BackgroundJobState" ADD COLUMN "consecutiveFailures" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "BackgroundJobState" ADD COLUMN "lastFailureAt" DATETIME; ALTER TABLE "BackgroundJobState" ADD COLUMN "lastFailureAt" TIMESTAMP(3);

View File

@ -4,10 +4,10 @@ CREATE TABLE "AuthToken" (
"tenantId" TEXT NOT NULL, "tenantId" TEXT NOT NULL,
"tokenType" TEXT NOT NULL, "tokenType" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL, "tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL, "expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" DATETIME, "consumedAt" TIMESTAMP(3),
"createdByUser" TEXT, "createdByUser" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metadataJson" JSONB, "metadataJson" JSONB,
CONSTRAINT "AuthToken_tokenHash_key" UNIQUE ("tokenHash"), CONSTRAINT "AuthToken_tokenHash_key" UNIQUE ("tokenHash"),
CONSTRAINT "AuthToken_tokenType_check" CHECK ("tokenType" IN ('PASSWORD_RESET', 'INVITE_ACCEPTANCE')) CONSTRAINT "AuthToken_tokenType_check" CHECK ("tokenType" IN ('PASSWORD_RESET', 'INVITE_ACCEPTANCE'))

View File

@ -3,7 +3,7 @@ generator client {
} }
datasource db { datasource db {
provider = "sqlite" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }

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