Compare commits
11 Commits
1abd89907c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 311551c31a | |||
| c84ce90fcf | |||
| 90f794bfe2 | |||
| 7f15725599 | |||
| 681e2667e4 | |||
| 6c6ed15c31 | |||
| 70183fe23e | |||
| fbaf39e52a | |||
| 87180e1858 | |||
| 43f33edc8b | |||
| 3244aeeba9 |
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
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.
|
||||
12
lib/auth.ts
12
lib/auth.ts
@ -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
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}`);
|
||||
}
|
||||
106
middleware.ts
106
middleware.ts
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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")
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -3,7 +3,7 @@ generator client {
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user