Compare commits

..

11 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
14 changed files with 371 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"
WHATSAPP_API_TOKEN="your-meta-token"
WHATSAPP_API_VERSION="v22.0"

View File

@ -1,9 +1,17 @@
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 { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { getRequestBaseUrl } from "@/lib/request-url";
function getSafePath(value: string | null) {
if (!value) {
@ -14,6 +22,10 @@ function getSafePath(value: string | null) {
return null;
}
if (value.startsWith("//")) {
return null;
}
return value;
}
@ -26,8 +38,42 @@ function resolveNumber(raw: string | undefined, fallback: number) {
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) {
const { ipAddress, userAgent } = await getRequestAuditContext();
const baseUrl = getRequestBaseUrl(request);
const retryControl = consumeRateLimit(ipAddress || "unknown", {
scope: "auth_login",
limit: resolveNumber(process.env.LOGIN_RATE_LIMIT_ATTEMPTS, 10),
@ -35,7 +81,7 @@ export async function POST(request: NextRequest) {
});
if (!retryControl.allowed) {
const loginUrl = new URL("/login", request.url);
const loginUrl = new URL("/login", baseUrl);
loginUrl.searchParams.set("error", "rate_limited");
const response = NextResponse.redirect(loginUrl);
const headers = getRateLimitHeaders(retryControl);
@ -53,9 +99,18 @@ export async function POST(request: NextRequest) {
const next = getSafePath(typeof rawNext === "string" ? rawNext : null);
const email = typeof rawEmail === "string" ? rawEmail.trim() : "";
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) {
const loginUrl = new URL("/login", request.url);
const loginUrl = new URL("/login", baseUrl);
loginUrl.searchParams.set("error", "credentials_required");
if (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");
if (next) {
loginUrl.searchParams.set("next", next);
}
if (AUTH_DEBUG) {
console.warn("[AUTH] login_failed", {
email: maskEmail(email),
ipAddress,
userAgent
});
}
return NextResponse.redirect(loginUrl);
}
@ -116,14 +179,46 @@ export async function POST(request: NextRequest) {
});
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), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
secure: shouldUseSecureCookies(request),
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;
}

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
import { getSession, SESSION_COOKIE } from "@/lib/auth";
import { getRequestBaseUrl } from "@/lib/request-url";
export async function GET(request: NextRequest) {
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);
return response;
}

View File

@ -27,9 +27,9 @@ export default async function LoginPage({
: null;
return (
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-14">
<div className="grid w-full max-w-5xl overflow-hidden rounded-[2rem] bg-surface-container-lowest shadow-floating">
<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">
<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 md:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<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
src="/logo_zappcare.png"
alt="ZappCare"
@ -38,22 +38,12 @@ export default async function LoginPage({
className="mx-auto h-14 w-auto rounded-full"
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">
{t("login", "signin_subtitle")}
</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 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">
<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>
@ -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>
</div>
</label>
<Button className="w-full">{t("login", "sign_in_button")}</Button>
<Button type="submit" className="w-full">{t("login", "sign_in_button")}</Button>
<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"

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

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 { 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"];
@ -9,12 +12,52 @@ function isPublicPath(pathname: string) {
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) {
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) {
const { pathname } = request.nextUrl;
const baseUrl = getRequestBaseUrl(request);
const response = NextResponse.next();
if (pathname.startsWith("/_next") || pathname.includes(".")) {
@ -31,32 +74,83 @@ export async function middleware(request: NextRequest) {
response.cookies.set(LOCALE_COOKIE, detected, {
path: "/",
maxAge: 365 * 24 * 60 * 60,
secure: process.env.NODE_ENV === "production",
secure: shouldUseSecureCookies(request),
sameSite: "lax"
});
} else if (!isLocale(localeCookie)) {
response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, {
path: "/",
maxAge: 365 * 24 * 60 * 60,
secure: process.env.NODE_ENV === "production",
secure: shouldUseSecureCookies(request),
sameSite: "lax"
});
}
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);
return NextResponse.redirect(loginUrl);
}
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)) {
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;
}

View File

@ -8,8 +8,8 @@ CREATE TABLE "SubscriptionPlan" (
"seatQuota" INTEGER NOT NULL,
"broadcastQuota" INTEGER NOT NULL,
"featuresJson" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
-- CreateTable
@ -21,8 +21,8 @@ CREATE TABLE "Tenant" (
"timezone" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'TRIAL',
"planId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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,
"code" TEXT NOT NULL,
"permissionsJson" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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,
"status" TEXT NOT NULL DEFAULT 'INVITED',
"avatarUrl" TEXT,
"lastLoginAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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
);
@ -66,9 +66,9 @@ CREATE TABLE "Channel" (
"displayPhoneNumber" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"webhookStatus" TEXT,
"lastSyncAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"lastSyncAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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,
"countryCode" TEXT,
"optInStatus" TEXT NOT NULL DEFAULT 'UNKNOWN',
"lastInteractionAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"lastInteractionAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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
);
@ -97,8 +97,8 @@ CREATE TABLE "Tag" (
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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,
"contactId" 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_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',
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"assignedUserId" TEXT,
"firstMessageAt" DATETIME,
"lastMessageAt" DATETIME,
"lastInboundAt" DATETIME,
"lastOutboundAt" DATETIME,
"resolvedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"firstMessageAt" TIMESTAMP(3),
"lastMessageAt" TIMESTAMP(3),
"lastInboundAt" TIMESTAMP(3),
"lastOutboundAt" TIMESTAMP(3),
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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_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',
"failedReason" TEXT,
"sentByUserId" TEXT,
"sentAt" DATETIME,
"deliveredAt" DATETIME,
"readAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sentAt" TIMESTAMP(3),
"deliveredAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"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_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,
@ -171,8 +171,8 @@ CREATE TABLE "ConversationNote" (
"conversationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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_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,
"conversationId" 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_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
@ -197,7 +197,7 @@ CREATE TABLE "ConversationActivity" (
"actorUserId" TEXT,
"activityType" TEXT NOT NULL,
"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_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
@ -210,8 +210,8 @@ CREATE TABLE "ContactSegment" (
"name" TEXT NOT NULL,
"description" TEXT,
"rulesJson" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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,
"segmentId" 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_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
@ -242,8 +242,8 @@ CREATE TABLE "MessageTemplate" (
"providerTemplateId" TEXT,
"approvalStatus" TEXT NOT NULL DEFAULT 'DRAFT',
"rejectedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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
);
@ -259,17 +259,17 @@ CREATE TABLE "BroadcastCampaign" (
"campaignType" TEXT NOT NULL DEFAULT 'BROADCAST',
"audienceType" TEXT NOT NULL,
"segmentId" TEXT,
"scheduledAt" DATETIME,
"startedAt" DATETIME,
"finishedAt" DATETIME,
"scheduledAt" TIMESTAMP(3),
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"totalRecipients" INTEGER NOT NULL DEFAULT 0,
"totalSent" INTEGER NOT NULL DEFAULT 0,
"totalDelivered" INTEGER NOT NULL DEFAULT 0,
"totalRead" INTEGER NOT NULL DEFAULT 0,
"totalFailed" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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_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',
"failureReason" TEXT,
"providerMessageId" TEXT,
"sentAt" DATETIME,
"deliveredAt" DATETIME,
"readAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sentAt" TIMESTAMP(3),
"deliveredAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"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_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
@ -306,7 +306,7 @@ CREATE TABLE "AuditLog" (
"metadataJson" JSONB,
"ipAddress" 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_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,
"processStatus" TEXT NOT NULL,
"failedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" DATETIME,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
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
);
@ -331,15 +331,15 @@ CREATE TABLE "WebhookEvent" (
CREATE TABLE "UsageMetric" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"metricDate" DATETIME NOT NULL,
"metricDate" TIMESTAMP(3) NOT NULL,
"inboundMessages" INTEGER NOT NULL DEFAULT 0,
"outboundMessages" INTEGER NOT NULL DEFAULT 0,
"activeContacts" INTEGER NOT NULL DEFAULT 0,
"activeAgents" INTEGER NOT NULL DEFAULT 0,
"broadcastSent" INTEGER NOT NULL DEFAULT 0,
"storageUsedMb" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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,
"planId" TEXT NOT NULL,
"invoiceNumber" TEXT NOT NULL,
"periodStart" DATETIME NOT NULL,
"periodEnd" DATETIME NOT NULL,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"subtotal" DECIMAL NOT NULL,
"taxAmount" DECIMAL NOT NULL,
"totalAmount" DECIMAL NOT NULL,
"paymentStatus" TEXT NOT NULL DEFAULT 'UNPAID',
"dueDate" DATETIME NOT NULL,
"paidAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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
);

View File

@ -1,4 +1,4 @@
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 "lastAttemptAt" DATETIME;
ALTER TABLE "CampaignRecipient" ADD COLUMN "nextRetryAt" DATETIME;
ALTER TABLE "CampaignRecipient" ADD COLUMN "lastAttemptAt" TIMESTAMP(3);
ALTER TABLE "CampaignRecipient" ADD COLUMN "nextRetryAt" TIMESTAMP(3);

View File

@ -2,15 +2,15 @@ CREATE TABLE "BackgroundJobState" (
"id" TEXT NOT NULL PRIMARY KEY,
"jobName" TEXT NOT NULL,
"lockedBy" TEXT NOT NULL,
"lockedUntil" DATETIME,
"lockedUntil" TIMESTAMP(3),
"runs" INTEGER NOT NULL DEFAULT 0,
"lastRunStartedAt" DATETIME,
"lastRunCompletedAt" DATETIME,
"lastRunStartedAt" TIMESTAMP(3),
"lastRunCompletedAt" TIMESTAMP(3),
"lastRunStatus" TEXT,
"lastRunSummaryJson" JSONB,
"lastError" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
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 "lastFailureAt" DATETIME;
ALTER TABLE "BackgroundJobState" ADD COLUMN "lastFailureAt" TIMESTAMP(3);

View File

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

View File

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