Compare commits

...

13 Commits

Author SHA1 Message Date
311551c31a fix(auth): stabilize cookie domain handling behind proxy and add auth debug logs
Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
2026-04-21 17:36:32 +07:00
c84ce90fcf fix: fallback to signed session payload when DB user row is missing
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:39:05 +07:00
90f794bfe2 fix: validate login redirect target by role
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:34:48 +07:00
7f15725599 fix: adjust login screen height and remove summary cards
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:28:12 +07:00
681e2667e4 fix: respect next param on authenticated /login redirect
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:27:10 +07:00
6c6ed15c31 fix: use forwarded host for auth redirects
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:18:13 +07:00
70183fe23e fix: make login button submit auth form
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 13:11:41 +07:00
fbaf39e52a fix: replace DATETIME with TIMESTAMP for PostgreSQL migrations
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 12:32:11 +07:00
87180e1858 fix: make initial migration PostgreSQL-compatible
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 12:27:29 +07:00
43f33edc8b chore: switch prisma datasource to postgresql
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 12:21:51 +07:00
3244aeeba9 docs: add concise project context summary
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 10:56:04 +07:00
1abd89907c chore: ignore local env/db artifacts
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
2026-04-21 09:37:43 +07:00
a08224d16f docs: add production install guide from git source 2026-04-21 09:35:15 +07:00
16 changed files with 656 additions and 103 deletions

View File

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

4
.gitignore vendored
View File

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

View 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.

View File

@ -1,9 +1,17 @@
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,
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 +22,10 @@ function getSafePath(value: string | null) {
return null; return null;
} }
if (value.startsWith("//")) {
return null;
}
return value; return value;
} }
@ -26,8 +38,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 = 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");
}
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 +81,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 +99,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 +143,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 +179,46 @@ 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)) maxAge: sessionMaxAgeSeconds
}); });
if (AUTH_DEBUG) {
console.warn("[AUTH] session_cookie_issued", {
userId: session.userId,
role: session.role,
sessionExpiresAt: session.expiresAt,
maxAge: sessionMaxAgeSeconds,
host: request.headers.get("host") || "unknown",
protocol: request.nextUrl.protocol,
forwardedProto: request.headers.get("x-forwarded-proto") || "unknown",
secureCookies: shouldUseSecureCookies(request)
});
console.warn("[AUTH] login_success_redirect", {
userId: session.userId,
destination: safeDestination,
setCookie: response.headers.get("set-cookie")
});
}
response.headers.set("X-Auth-Session", "issued");
response.headers.set("X-Auth-Session-User", session.userId);
response.headers.set("X-Auth-Session-Role", session.role);
response.headers.set("X-Auth-Session-Base-Url", baseUrl.toString());
response.headers.set("X-Auth-Session-Max-Age", String(sessionMaxAgeSeconds));
response.headers.set("X-Auth-Session-Secure", String(shouldUseSecureCookies(request)));
return response; return response;
} }

View File

@ -2,6 +2,7 @@ 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 GET(request: NextRequest) {
const session = await getSession(); const session = await getSession();
@ -20,7 +21,7 @@ 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;
} }

View File

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

58
context.txt Normal file
View File

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

View File

@ -269,7 +269,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 {

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

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

View File

@ -2,6 +2,9 @@ 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";
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 +12,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(".")) {
@ -31,32 +74,83 @@ export async function middleware(request: NextRequest) {
response.cookies.set(LOCALE_COOKIE, detected, { response.cookies.set(LOCALE_COOKIE, detected, {
path: "/", path: "/",
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: "/",
maxAge: 365 * 24 * 60 * 60, maxAge: 365 * 24 * 60 * 60,
secure: process.env.NODE_ENV === "production", secure: shouldUseSecureCookies(request),
sameSite: "lax" sameSite: "lax"
}); });
} }
if (!session && !isPublicPath(pathname) && pathname !== "/") { if (!session && !isPublicPath(pathname) && pathname !== "/") {
const loginUrl = new URL("/login", request.url); const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unknown";
debugAuth("missing_or_invalid_session", {
pathname,
method: request.method,
ip: clientIp,
userAgent: request.headers.get("user-agent") || "unknown",
hasSessionCookie: Boolean(sessionCookie),
sessionCookieLength: sessionCookie?.length || 0,
host: request.headers.get("host") || "unknown",
protocol: request.nextUrl.protocol,
forwardedProto: request.headers.get("x-forwarded-proto") || "unknown",
secureCookies: shouldUseSecureCookies(request)
});
const loginUrl = new URL("/login", baseUrl);
loginUrl.searchParams.set("next", pathname); loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
if (session && (pathname === "/" || pathname === "/login")) { if (session && (pathname === "/" || pathname === "/login")) {
return NextResponse.redirect(new URL(getDefaultPathForRole(session.role), request.url)); const requested = request.nextUrl.searchParams.get("next");
const hasSafeNext = typeof requested === "string" && requested.startsWith("/") && !requested.startsWith("//");
const nextPath = hasSafeNext ? requested : null;
const destination = nextPath && canAccessPath(session.role, nextPath) ? nextPath : getDefaultPathForRole(session.role);
const redirectResponse = NextResponse.redirect(new URL(destination, baseUrl));
setDebugHeaders(redirectResponse, {
"X-Auth-Session": "valid",
"X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)),
"X-Auth-Session-Role": session.role,
"X-Auth-Path": pathname,
"X-Auth-Base-Url": baseUrl.toString()
});
return redirectResponse;
} }
if (session && !isPublicPath(pathname) && !canAccessPath(session.role, pathname)) { if (session && !isPublicPath(pathname) && !canAccessPath(session.role, pathname)) {
return NextResponse.redirect(new URL("/unauthorized", request.url)); debugAuth("role_forbidden", {
pathname,
role: session.role
});
const forbiddenResponse = NextResponse.redirect(new URL("/unauthorized", baseUrl));
setDebugHeaders(forbiddenResponse, {
"X-Auth-Session": "forbidden",
"X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)),
"X-Auth-Session-Role": session.role,
"X-Auth-Path": pathname,
"X-Auth-Base-Url": baseUrl.toString()
});
return forbiddenResponse;
} }
setDebugHeaders(response, {
"X-Auth-Session": session ? "valid" : "missing",
"X-Auth-Session-Has-Cookie": String(Boolean(sessionCookie)),
"X-Auth-Session-Valid-Role": session?.role || "n/a",
"X-Auth-Path": pathname,
"X-Auth-Base-Url": baseUrl.toString(),
"X-Auth-Host": request.headers.get("host") || "unknown"
});
return response; return response;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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