Compare commits
15 Commits
adde003fba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 137edc12b7 | |||
| f48c87e36d | |||
| 311551c31a | |||
| c84ce90fcf | |||
| 90f794bfe2 | |||
| 7f15725599 | |||
| 681e2667e4 | |||
| 6c6ed15c31 | |||
| 70183fe23e | |||
| fbaf39e52a | |||
| 87180e1858 | |||
| 43f33edc8b | |||
| 3244aeeba9 | |||
| 1abd89907c | |||
| a08224d16f |
@ -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"
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -2,3 +2,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dev.db
|
dev.db
|
||||||
dev.db-journal
|
dev.db-journal
|
||||||
|
.env
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-journal
|
||||||
|
.DS_Store
|
||||||
|
|||||||
281
INSTALL-UBUNTU-APP-ZAPPCARE-FROM-GIT.md
Normal file
281
INSTALL-UBUNTU-APP-ZAPPCARE-FROM-GIT.md
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
# Deploy WhatsApp Inbox dari Gitea (Ubuntu)
|
||||||
|
|
||||||
|
Dokumen ini untuk deploy `whatsapp-inbox-platform` ke Ubuntu production dengan asumsi:
|
||||||
|
|
||||||
|
- PostgreSQL sudah terinstall
|
||||||
|
- Nginx sudah terinstall
|
||||||
|
- Gitea berjalan di port `3001`
|
||||||
|
- App domain: `app.zappcare.id`
|
||||||
|
- Port `3000` tidak dipakai (dalam panduan ini dipakai `3002`)
|
||||||
|
|
||||||
|
Source code proyek sudah ada di Git:
|
||||||
|
|
||||||
|
- `https://git.iptek.co/wirabasalamah/whatsapp-inbox-platform.git`
|
||||||
|
|
||||||
|
## 0) Persiapan dasar
|
||||||
|
|
||||||
|
Login sebagai user sudo di server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh user@YOUR_SERVER_IP
|
||||||
|
```
|
||||||
|
|
||||||
|
Update sistem & install dependency dasar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
sudo apt install -y curl ca-certificates git nginx postgresql postgresql-contrib
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1) Buat user & direktori aplikasi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --home /var/www/whatsapp-inbox --shell /usr/sbin/nologin whatsapp-inbox || true
|
||||||
|
sudo mkdir -p /var/www/whatsapp-inbox
|
||||||
|
sudo chown -R whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Buat DB PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER whatsapp_inbox WITH PASSWORD 'YOUR_DB_PASSWORD';
|
||||||
|
CREATE DATABASE whatsapp_inbox OWNER whatsapp_inbox;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Clone source dari Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u whatsapp-inbox git clone https://git.iptek.co/wirabasalamah/whatsapp-inbox-platform.git /var/www/whatsapp-inbox
|
||||||
|
cd /var/www/whatsapp-inbox
|
||||||
|
git checkout main
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Catatan:
|
||||||
|
- jika prompt autentikasi muncul, gunakan token/credential Gitea Anda (username: `wira` atau email, token lebih aman daripada password).
|
||||||
|
- jangan simpan password Git di file lain di repo.
|
||||||
|
|
||||||
|
## 4) Install dependency
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/whatsapp-inbox
|
||||||
|
sudo -u whatsapp-inbox npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) Setup environment `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u whatsapp-inbox cp .env.example .env
|
||||||
|
sudo -u whatsapp-inbox nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal konfigurasi:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=3002
|
||||||
|
|
||||||
|
DATABASE_URL="postgresql://whatsapp_inbox:YOUR_DB_PASSWORD@127.0.0.1:5432/whatsapp_inbox?schema=public"
|
||||||
|
AUTH_SECRET="ganti_secret_acak_minimal_32_karakter"
|
||||||
|
|
||||||
|
APP_URL="https://app.zappcare.id"
|
||||||
|
NEXT_PUBLIC_APP_URL="https://app.zappcare.id"
|
||||||
|
OPS_BASE_URL="https://app.zappcare.id"
|
||||||
|
|
||||||
|
WHATSAPP_WEBHOOK_VERIFY_TOKEN="ganti_verify_token"
|
||||||
|
WHATSAPP_WEBHOOK_SECRET="ganti_webhook_secret"
|
||||||
|
WHATSAPP_API_TOKEN="ganti_meta_token"
|
||||||
|
WHATSAPP_API_VERSION="v22.0"
|
||||||
|
WHATSAPP_ALLOW_SIMULATED_SEND="false"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tambahkan variabel lain sesuai kebutuhan operasi/retry dari `.env.example`.
|
||||||
|
|
||||||
|
## 6) Migrasi & seed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/whatsapp-inbox
|
||||||
|
sudo -u whatsapp-inbox npm run db:deploy
|
||||||
|
sudo -u whatsapp-inbox npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7) Uji jalur aplikasi (manual)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/whatsapp-inbox
|
||||||
|
sudo -u whatsapp-inbox npm run start -- --hostname 127.0.0.1 --port 3002
|
||||||
|
```
|
||||||
|
|
||||||
|
Di terminal lain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I http://127.0.0.1:3002
|
||||||
|
curl -s http://127.0.0.1:3002/api/health | cat
|
||||||
|
```
|
||||||
|
|
||||||
|
Tekan `Ctrl+C` setelah semua sehat lalu lanjut ke service/systemd.
|
||||||
|
|
||||||
|
## 8) Buat service systemd untuk app
|
||||||
|
|
||||||
|
Buat `/etc/systemd/system/whatsapp-inbox.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=WhatsApp Inbox (Next.js)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=whatsapp-inbox
|
||||||
|
Group=whatsapp-inbox
|
||||||
|
WorkingDirectory=/var/www/whatsapp-inbox
|
||||||
|
EnvironmentFile=/var/www/whatsapp-inbox/.env
|
||||||
|
ExecStart=/usr/bin/npm run start -- --hostname 127.0.0.1 --port 3002
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now whatsapp-inbox
|
||||||
|
sudo systemctl status whatsapp-inbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9) Buat service retry worker
|
||||||
|
|
||||||
|
Buat `/etc/systemd/system/whatsapp-inbox-retry.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=WhatsApp Inbox Campaign Retry Daemon
|
||||||
|
After=network.target whatsapp-inbox.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=whatsapp-inbox
|
||||||
|
Group=whatsapp-inbox
|
||||||
|
WorkingDirectory=/var/www/whatsapp-inbox
|
||||||
|
EnvironmentFile=/var/www/whatsapp-inbox/.env
|
||||||
|
ExecStart=/usr/bin/npm run job:campaign-retry:daemon
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now whatsapp-inbox-retry
|
||||||
|
sudo systemctl status whatsapp-inbox-retry
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10) Konfigurasi Nginx + HTTPS ke app.zappcare.id
|
||||||
|
|
||||||
|
Buat `/etc/nginx/sites-available/app.zappcare.id`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name app.zappcare.id;
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name app.zappcare.id;
|
||||||
|
client_max_body_size 20m;
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/app.zappcare.id/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/app.zappcare.id/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3002;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktifkan dan reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/app.zappcare.id /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Install certbot dan issue SSL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d app.zappcare.id
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11) Validasi akhir
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://app.zappcare.id
|
||||||
|
curl -s https://app.zappcare.id/api/health | cat
|
||||||
|
APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:readiness
|
||||||
|
APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12) Update dan rollback ringan
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/whatsapp-inbox
|
||||||
|
git pull origin main
|
||||||
|
sudo -u whatsapp-inbox npm ci
|
||||||
|
sudo -u whatsapp-inbox npm run db:deploy
|
||||||
|
sudo -u whatsapp-inbox npm run ci:verify
|
||||||
|
sudo systemctl restart whatsapp-inbox
|
||||||
|
sudo systemctl restart whatsapp-inbox-retry
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback cepat (jika perlu):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/whatsapp-inbox
|
||||||
|
git log --oneline -n 5
|
||||||
|
git checkout <commit-id-sebelumnya>
|
||||||
|
sudo systemctl restart whatsapp-inbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13) Catatan produksi
|
||||||
|
|
||||||
|
- App service berjalan di `127.0.0.1:3002` (internal), Nginx mengekspose ke `https://app.zappcare.id`.
|
||||||
|
- Tidak menyimpan secrets di git.
|
||||||
|
- Gunakan `npm run ops:readiness` secara berkala setelah deploy/reboot.
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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
58
context.txt
Normal 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
189
docs/code-documentation.md
Normal 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
320
docs/setup-guide.md
Normal 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 Let’s Encrypt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d web.zappcare.id
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload nginx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## D. Deployment Routine Setelah Update
|
||||||
|
|
||||||
|
### D0. Alur source code ke Git
|
||||||
|
|
||||||
|
Kerjakan perubahan di folder lokal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wira/work/whatsapp-inbox-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Pastikan file sensitif seperti `.env` tidak ikut di-commit. Repo ini sudah meng-ignore `.env`.
|
||||||
|
|
||||||
|
Command Git manual dari lokal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wira/work/whatsapp-inbox-platform
|
||||||
|
git status
|
||||||
|
git add .
|
||||||
|
git commit -m "fix: deskripsi perubahan"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika ingin cek commit terakhir yang sudah siap diambil server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wira/work/whatsapp-inbox-platform
|
||||||
|
git log --oneline -n 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### D1. Pull dan jalankan restart aman
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/whatsapp-inbox-platform
|
||||||
|
git pull origin main
|
||||||
|
npm run ops:safe-restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika app di server dijalankan dengan user deploy `whatsapp-inbox`, gunakan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main && npm run ops:safe-restart'
|
||||||
|
```
|
||||||
|
|
||||||
|
Command di atas adalah jalur patch utama untuk update source di server.
|
||||||
|
|
||||||
|
### D1.1. Patch manual di server langkah demi langkah
|
||||||
|
|
||||||
|
Gunakan ini jika ingin melihat proses satu per satu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git status'
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main'
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm ci'
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run build'
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run db:deploy'
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run ops:safe-restart'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gunakan jalur manual ini jika:
|
||||||
|
|
||||||
|
- ada perubahan dependency baru
|
||||||
|
- ada migration baru
|
||||||
|
- ingin memastikan step build berhasil sebelum restart
|
||||||
|
|
||||||
|
### D1.2. Patch cepat tanpa perubahan dependency
|
||||||
|
|
||||||
|
Kalau hanya ubah source biasa dan `package-lock.json` tidak berubah:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main && npm run build && npm run ops:safe-restart'
|
||||||
|
```
|
||||||
|
|
||||||
|
### D2. Verifikasi pasca deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/whatsapp-inbox-platform
|
||||||
|
npm run ops:readiness
|
||||||
|
npm run ops:session-check
|
||||||
|
npm run ops:healthcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
### D3. Verifikasi proses berjalan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 list
|
||||||
|
sudo ss -ltnp | grep 3002
|
||||||
|
```
|
||||||
|
|
||||||
|
## E. Checklist Pasca Login Issue
|
||||||
|
|
||||||
|
Jika masih kena loop ke login:
|
||||||
|
|
||||||
|
- cek cookie `wa_inbox_session` di browser.
|
||||||
|
- cek domain/cookie secure.
|
||||||
|
- cek header:
|
||||||
|
- `X-Auth-Session`
|
||||||
|
- `X-Auth-Session-Has-Cookie`
|
||||||
|
- `X-Auth-Base-Url`
|
||||||
|
- cek logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 logs whatsapp-inbox-platform --lines 200
|
||||||
|
```
|
||||||
|
|
||||||
|
- cek ENV:
|
||||||
|
- `COOKIE_SECURE=true` untuk HTTPS.
|
||||||
|
- `APP_URL` harus `https://web.zappcare.id`.
|
||||||
|
- `SESSION_COOKIE_DOMAIN` kalau lintas subdomain.
|
||||||
|
|
||||||
|
## F. Jalur Emergency/Recovery
|
||||||
|
|
||||||
|
Jika app tidak start:
|
||||||
|
|
||||||
|
- `npm run ops:readiness`
|
||||||
|
- cek DB connect.
|
||||||
|
- jalankan ulang migration jika schema mismatch.
|
||||||
|
- run `npm run ops:safe-restart`.
|
||||||
|
- jika perlu rollback, checkout commit sebelumnya lalu restart ulang.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -n 10
|
||||||
|
git checkout <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`
|
||||||
@ -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;
|
||||||
|
|||||||
57
lib/auth.ts
57
lib/auth.ts
@ -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 {
|
||||||
|
|||||||
@ -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
19
lib/request-url.ts
Normal 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}`);
|
||||||
|
}
|
||||||
109
middleware.ts
109
middleware.ts
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
@ -3,7 +3,7 @@ generator client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
44
scripts/ops-safe-restart.sh
Executable 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"
|
||||||
189
scripts/ops-session-check.mjs
Normal file
189
scripts/ops-session-check.mjs
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user