Initial BizOne portal setup

This commit is contained in:
2026-05-11 11:36:33 +07:00
commit 57017dd397
249 changed files with 41305 additions and 0 deletions

View File

@ -0,0 +1,30 @@
CREATE TABLE users (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE contacts (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
phone_number TEXT NOT NULL UNIQUE,
email TEXT,
company TEXT,
notes TEXT,
is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE webhook_events (
id UUID PRIMARY KEY,
provider TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json JSONB NOT NULL,
processing_status TEXT NOT NULL DEFAULT 'received',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1,21 @@
ALTER TABLE webhook_events
ADD COLUMN event_id TEXT,
ADD COLUMN sender_phone TEXT,
ADD COLUMN recipient_phone TEXT,
ADD COLUMN external_message_id TEXT,
ADD COLUMN event_timestamp TIMESTAMP,
ADD COLUMN verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN processing_notes TEXT,
ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT NOW();
UPDATE webhook_events
SET event_id = id::text,
event_timestamp = created_at
WHERE event_id IS NULL
OR event_timestamp IS NULL;
ALTER TABLE webhook_events
ALTER COLUMN event_id SET NOT NULL,
ALTER COLUMN event_timestamp SET NOT NULL;
CREATE UNIQUE INDEX webhook_events_event_id_key ON webhook_events(event_id);

View File

@ -0,0 +1,28 @@
CREATE TABLE integration_configs (
id UUID PRIMARY KEY,
config_key TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
config_json JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE jobs (
id UUID PRIMARY KEY,
queue_name TEXT NOT NULL,
job_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
payload_json JSONB NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
available_at TIMESTAMP NOT NULL DEFAULT NOW(),
processed_at TIMESTAMP,
failed_at TIMESTAMP,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX jobs_queue_name_status_available_at_idx
ON jobs(queue_name, status, available_at);

View File

@ -0,0 +1,18 @@
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
actor_user_id TEXT,
actor_name TEXT NOT NULL,
actor_email TEXT,
action_type TEXT NOT NULL,
module TEXT NOT NULL,
ip_address TEXT,
severity TEXT NOT NULL DEFAULT 'default',
details TEXT NOT NULL,
metadata_json JSONB,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX audit_logs_created_at_idx ON audit_logs(created_at);
CREATE INDEX audit_logs_actor_name_idx ON audit_logs(actor_name);
CREATE INDEX audit_logs_action_type_idx ON audit_logs(action_type);
CREATE INDEX audit_logs_module_idx ON audit_logs(module);

View File

@ -0,0 +1,21 @@
CREATE TABLE roles (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
summary TEXT NOT NULL,
badge TEXT NOT NULL,
tone TEXT NOT NULL,
icon TEXT NOT NULL,
permissions_json JSONB NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE users ADD COLUMN role_id TEXT;
ALTER TABLE users
ADD CONSTRAINT users_role_id_fkey
FOREIGN KEY (role_id) REFERENCES roles(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
CREATE INDEX users_role_id_idx ON users(role_id);

View File

@ -0,0 +1,15 @@
ALTER TYPE "UserStatus" ADD VALUE IF NOT EXISTS 'invited';
ALTER TABLE "users"
ALTER COLUMN "password_hash" DROP NOT NULL,
ALTER COLUMN "status" SET DEFAULT 'invited',
ADD COLUMN IF NOT EXISTS "invite_token_hash" TEXT,
ADD COLUMN IF NOT EXISTS "invite_token_expires_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "email_verified_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "last_login_at" TIMESTAMP(3);
UPDATE "users"
SET "status" = 'active'
WHERE "status" IS NULL OR "status"::text = 'active';
CREATE INDEX IF NOT EXISTS "users_invite_token_hash_idx" ON "users"("invite_token_hash");

View File

@ -0,0 +1,47 @@
CREATE TABLE campaigns (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
audience_label TEXT NOT NULL,
audience_group TEXT NOT NULL,
status TEXT NOT NULL,
total_recipients INTEGER NOT NULL DEFAULT 0,
delivered_count INTEGER NOT NULL DEFAULT 0,
read_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
delivery_rate DOUBLE PRECISION,
read_rate DOUBLE PRECISION,
sent_at TIMESTAMP(3),
scheduled_at TIMESTAMP(3),
template_name TEXT,
language TEXT,
message_title TEXT,
message_body TEXT,
primary_button TEXT,
secondary_button TEXT,
banner_image_url TEXT,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE campaign_recipients (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
status TEXT NOT NULL,
sent_at TIMESTAMP(3),
error_reason TEXT,
device_os TEXT,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT campaign_recipients_campaign_id_fkey
FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
CREATE INDEX campaign_recipients_campaign_id_status_idx
ON campaign_recipients(campaign_id, status);
CREATE INDEX campaign_recipients_campaign_id_sent_at_idx
ON campaign_recipients(campaign_id, sent_at);

View File

@ -0,0 +1,4 @@
ALTER TABLE "users"
ADD COLUMN "refresh_token_hash" TEXT,
ADD COLUMN "refresh_token_expires_at" TIMESTAMP(3),
ADD COLUMN "session_version" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,5 @@
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "password_reset_token_hash" TEXT,
ADD COLUMN IF NOT EXISTS "password_reset_token_expires_at" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "users_password_reset_token_hash_idx" ON "users"("password_reset_token_hash");

View File

@ -0,0 +1,5 @@
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "two_factor_secret_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "two_factor_pending_secret_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "two_factor_confirmed_at" TIMESTAMP(3);

View File

@ -0,0 +1,2 @@
ALTER TABLE "users"
ADD COLUMN "two_factor_recovery_codes_hash_json" JSONB;

View File

@ -0,0 +1,23 @@
CREATE TABLE "conversation_messages" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"contact_id" UUID NOT NULL,
"direction" TEXT NOT NULL,
"message_type" TEXT NOT NULL DEFAULT 'text',
"source" TEXT NOT NULL DEFAULT 'agent',
"body" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'sent',
"sender_user_id" UUID,
"sender_name" TEXT,
"external_message_id" TEXT,
"webhook_event_id" TEXT,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"read_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "conversation_messages_pkey" PRIMARY KEY ("id"),
CONSTRAINT "conversation_messages_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "conversation_messages_contact_id_occurred_at_idx" ON "conversation_messages"("contact_id", "occurred_at");
CREATE UNIQUE INDEX "conversation_messages_webhook_event_id_key" ON "conversation_messages"("webhook_event_id");

View File

@ -0,0 +1,4 @@
ALTER TABLE "contacts"
ADD COLUMN "assigned_user_id" UUID,
ADD COLUMN "assigned_user_name" TEXT,
ADD COLUMN "assigned_at" TIMESTAMP(3);

View File

@ -0,0 +1,16 @@
CREATE TABLE "message_templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" text NOT NULL UNIQUE,
"category" text NOT NULL,
"status" text NOT NULL,
"language" text NOT NULL,
"header_text" text,
"body_text" text NOT NULL,
"footer_text" text,
"buttons_json" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX "message_templates_status_category_language_idx"
ON "message_templates" ("status", "category", "language");

233
prisma/schema.prisma Normal file
View File

@ -0,0 +1,233 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserStatus {
invited
active
inactive
suspended
}
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String? @map("password_hash")
refreshTokenHash String? @map("refresh_token_hash")
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
sessionVersion Int @default(0) @map("session_version")
status UserStatus @default(invited)
roleId String? @map("role_id")
inviteTokenHash String? @map("invite_token_hash")
inviteTokenExpiresAt DateTime? @map("invite_token_expires_at")
passwordResetTokenHash String? @map("password_reset_token_hash")
passwordResetTokenExpiresAt DateTime? @map("password_reset_token_expires_at")
twoFactorEnabled Boolean @default(false) @map("two_factor_enabled")
twoFactorSecretEncrypted String? @map("two_factor_secret_encrypted")
twoFactorPendingSecretEncrypted String? @map("two_factor_pending_secret_encrypted")
twoFactorRecoveryCodesHashJson Json? @map("two_factor_recovery_codes_hash_json")
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at")
emailVerifiedAt DateTime? @map("email_verified_at")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
role Role? @relation(fields: [roleId], references: [id])
@@index([passwordResetTokenHash])
@@map("users")
}
model Role {
id String @id @default(uuid())
key String @unique
name String
summary String
badge String
tone String
icon String
permissionsJson Json @map("permissions_json")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
users User[]
@@map("roles")
}
model Contact {
id String @id @default(uuid())
name String
phoneNumber String @unique @map("phone_number")
email String?
company String?
notes String?
isBlacklisted Boolean @default(false) @map("is_blacklisted")
assignedUserId String? @map("assigned_user_id")
assignedUserName String? @map("assigned_user_name")
assignedAt DateTime? @map("assigned_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
conversationMessages ConversationMessage[]
@@map("contacts")
}
model ConversationMessage {
id String @id @default(uuid())
contactId String @map("contact_id")
direction String
messageType String @default("text") @map("message_type")
source String @default("agent")
body String
status String @default("sent")
senderUserId String? @map("sender_user_id")
senderName String? @map("sender_name")
externalMessageId String? @map("external_message_id")
webhookEventId String? @unique @map("webhook_event_id")
occurredAt DateTime @default(now()) @map("occurred_at")
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([contactId, occurredAt])
@@map("conversation_messages")
}
model WebhookEvent {
id String @id @default(uuid())
provider String
eventId String @unique @map("event_id")
eventType String @map("event_type")
senderPhone String? @map("sender_phone")
recipientPhone String? @map("recipient_phone")
externalMessageId String? @map("external_message_id")
eventTimestamp DateTime @map("event_timestamp")
payloadJson Json @map("payload_json")
verified Boolean @default(false)
processingStatus String @default("received") @map("processing_status")
processingNotes String? @map("processing_notes")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("webhook_events")
}
model IntegrationConfig {
id String @id @default(uuid())
configKey String @unique @map("config_key")
provider String
isEnabled Boolean @default(true) @map("is_enabled")
configJson Json @map("config_json")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("integration_configs")
}
model Job {
id String @id @default(uuid())
queueName String @map("queue_name")
jobType String @map("job_type")
status String @default("queued")
payloadJson Json @map("payload_json")
attempts Int @default(0)
maxAttempts Int @default(3) @map("max_attempts")
availableAt DateTime @default(now()) @map("available_at")
processedAt DateTime? @map("processed_at")
failedAt DateTime? @map("failed_at")
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([queueName, status, availableAt])
@@map("jobs")
}
model AuditLog {
id String @id @default(uuid())
actorUserId String? @map("actor_user_id")
actorName String @map("actor_name")
actorEmail String? @map("actor_email")
actionType String @map("action_type")
module String
ipAddress String? @map("ip_address")
severity String @default("default")
details String
metadataJson Json? @map("metadata_json")
createdAt DateTime @default(now()) @map("created_at")
@@index([createdAt])
@@index([actorName])
@@index([actionType])
@@index([module])
@@map("audit_logs")
}
model MessageTemplate {
id String @id @default(uuid())
name String @unique
category String
status String
language String
headerText String? @map("header_text")
bodyText String @map("body_text")
footerText String? @map("footer_text")
buttonsJson Json? @map("buttons_json")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([status, category, language])
@@map("message_templates")
}
model Campaign {
id String @id @default(uuid())
code String @unique
name String
audienceLabel String @map("audience_label")
audienceGroup String @map("audience_group")
status String
totalRecipients Int @default(0) @map("total_recipients")
deliveredCount Int @default(0) @map("delivered_count")
readCount Int @default(0) @map("read_count")
failedCount Int @default(0) @map("failed_count")
deliveryRate Float? @map("delivery_rate")
readRate Float? @map("read_rate")
sentAt DateTime? @map("sent_at")
scheduledAt DateTime? @map("scheduled_at")
templateName String? @map("template_name")
language String?
messageTitle String? @map("message_title")
messageBody String? @map("message_body")
primaryButton String? @map("primary_button")
secondaryButton String? @map("secondary_button")
bannerImageUrl String? @map("banner_image_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
recipients CampaignRecipient[]
@@map("campaigns")
}
model CampaignRecipient {
id String @id @default(uuid())
campaignId String @map("campaign_id")
phoneNumber String @map("phone_number")
status String
sentAt DateTime? @map("sent_at")
errorReason String? @map("error_reason")
deviceOs String? @map("device_os")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
@@index([campaignId, status])
@@index([campaignId, sentAt])
@@map("campaign_recipients")
}

View File

@ -0,0 +1,70 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const repoRoot = resolve(new URL('../..', import.meta.url).pathname);
const migrationsDir = resolve(repoRoot, 'prisma/migrations');
const schemaPath = resolve(repoRoot, 'prisma/schema.prisma');
const dryRun = process.argv.includes('--dry-run');
const prismaCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx';
for (const envPath of ['.env', '.env.local', 'backend/.env', 'backend/.env.local']) {
const absolutePath = resolve(repoRoot, envPath);
if (!existsSync(absolutePath)) {
continue;
}
const content = readFileSync(absolutePath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const separatorIndex = trimmed.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim();
if (key && !(key in process.env)) {
process.env[key] = value;
}
}
}
const migrations = readdirSync(migrationsDir)
.filter((name) => /^\d+_/.test(name))
.filter((name) => statSync(resolve(migrationsDir, name)).isDirectory())
.sort((left, right) => left.localeCompare(right));
if (migrations.length === 0) {
console.error('No Prisma migrations found.');
process.exit(1);
}
console.log(`Found ${migrations.length} migrations.`);
if (dryRun) {
migrations.forEach((migration) => console.log(`- ${migration}`));
process.exit(0);
}
for (const migration of migrations) {
console.log(`Marking migration as applied: ${migration}`);
const result = spawnSync(
prismaCommand,
['prisma', 'migrate', 'resolve', '--applied', migration, '--schema', schemaPath],
{
cwd: repoRoot,
stdio: 'inherit',
env: process.env,
},
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
console.log('Legacy Prisma baseline completed.');