feat: full clinera platform — auth, appointments, WhatsApp, chatbot canvas, reminders, leads, campaigns, inbox

This commit is contained in:
Obaid alah Saleh
2026-03-13 12:45:14 +02:00
parent fe044c91b4
commit 66a3568d01
195 changed files with 41621 additions and 4568 deletions
BIN
View File
Binary file not shown.
@@ -0,0 +1,114 @@
-- AI-Powered Tree-Based Reminder Sequence System
-- This migration adds the foundation for sophisticated reminder trees
-- Core Tree Definition
CREATE TABLE "ReminderTree" (
"id" TEXT NOT NULL PRIMARY KEY,
"clinicId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"triggerType" TEXT NOT NULL, -- booking_created | no_show | completed | custom
"isActive" BOOLEAN NOT NULL DEFAULT true,
"version" INTEGER NOT NULL DEFAULT 1,
"metadata" TEXT, -- JSON: A/B test groups, analytics tags, etc.
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
FOREIGN KEY ("clinicId") REFERENCES "Clinic" ("id") ON DELETE CASCADE
);
-- Individual nodes in the tree
CREATE TABLE "TreeNode" (
"id" TEXT NOT NULL PRIMARY KEY,
"treeId" TEXT NOT NULL,
"parentId" TEXT, -- NULL for root node
"name" TEXT NOT NULL,
"nodeType" TEXT NOT NULL, -- wait | send_message | send_buttons | condition | action | collect_input
"content" TEXT, -- Message template or condition logic
"offsetMinutes" INTEGER DEFAULT 0, -- Delay from parent execution
"conditions" TEXT, -- JSON: Complex conditions for routing
"metadata" TEXT, -- JSON: Additional node configuration
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("treeId") REFERENCES "ReminderTree" ("id") ON DELETE CASCADE,
FOREIGN KEY ("parentId") REFERENCES "TreeNode" ("id") ON DELETE CASCADE
);
-- Track user journey through trees
CREATE TABLE "UserJourney" (
"id" TEXT NOT NULL PRIMARY KEY,
"appointmentId" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"currentNodeId" TEXT,
"status" TEXT NOT NULL DEFAULT "active", -- active | completed | paused | failed
"journeyData" TEXT, -- JSON: Collected responses, scores, etc.
"startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastActiveAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" DATETIME,
FOREIGN KEY ("appointmentId") REFERENCES "Appointment" ("id") ON DELETE CASCADE,
FOREIGN KEY ("treeId") REFERENCES "ReminderTree" ("id") ON DELETE CASCADE,
FOREIGN KEY ("currentNodeId") REFERENCES "TreeNode" ("id") ON DELETE SET NULL
);
-- Track execution of individual tree nodes
CREATE TABLE "NodeExecution" (
"id" TEXT NOT NULL PRIMARY KEY,
"journeyId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT "pending", -- pending | executed | failed | skipped
"executedAt" DATETIME,
"response" TEXT, -- User response to this node
"metadata" TEXT, -- JSON: Execution details, errors, etc.
"nextNodeId" TEXT, -- Which node was selected as next
FOREIGN KEY ("journeyId") REFERENCES "UserJourney" ("id") ON DELETE CASCADE,
FOREIGN KEY ("nodeId") REFERENCES "TreeNode" ("id") ON DELETE CASCADE,
FOREIGN KEY ("nextNodeId") REFERENCES "TreeNode" ("id") ON DELETE SET NULL
);
-- Patient behavioral scoring system
CREATE TABLE "PatientProfile" (
"id" TEXT NOT NULL PRIMARY KEY,
"leadId" TEXT NOT NULL,
"clinicId" TEXT NOT NULL,
"responseStyle" TEXT NOT NULL DEFAULT "standard", -- quick | detailed | minimal | emoji_heavy
"preferredTime" TEXT, -- JSON: Hour ranges when they respond most
"engagementScore" REAL NOT NULL DEFAULT 50.0, -- 0-100 based on interaction quality
"noShowRisk" REAL NOT NULL DEFAULT 50.0, -- 0-100 predicted likelihood
"conversationTone" TEXT NOT NULL DEFAULT "neutral", -- formal | casual | friendly | professional
"languagePreference" TEXT NOT NULL DEFAULT "auto", -- auto | english | arabic
"lastInteractionAt" DATETIME,
"profileData" TEXT, -- JSON: Detailed behavioral analytics
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("leadId") REFERENCES "Lead" ("id") ON DELETE CASCADE,
FOREIGN KEY ("clinicId") REFERENCES "Clinic" ("id") ON DELETE CASCADE,
UNIQUE("leadId", "clinicId")
);
-- Smart A/B testing for different tree paths
CREATE TABLE "TreeExperiment" (
"id" TEXT NOT NULL PRIMARY KEY,
"clinicId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"controlTreeId" TEXT NOT NULL,
"variantTreeId" TEXT NOT NULL,
"trafficSplit" REAL NOT NULL DEFAULT 50.0, -- Percentage for variant
"status" TEXT NOT NULL DEFAULT "draft", -- draft | running | paused | completed
"startDate" DATETIME,
"endDate" DATETIME,
"metrics" TEXT, -- JSON: Conversion goals, success criteria
"results" TEXT, -- JSON: Experiment results
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("clinicId") REFERENCES "Clinic" ("id") ON DELETE CASCADE,
FOREIGN KEY ("controlTreeId") REFERENCES "ReminderTree" ("id") ON DELETE CASCADE,
FOREIGN KEY ("variantTreeId") REFERENCES "ReminderTree" ("id") ON DELETE CASCADE
);
-- Indexes for performance
CREATE INDEX "idx_reminder_tree_clinic" ON "ReminderTree"("clinicId", "isActive");
CREATE INDEX "idx_tree_node_tree" ON "TreeNode"("treeId", "sortOrder");
CREATE INDEX "idx_tree_node_parent" ON "TreeNode"("parentId");
CREATE INDEX "idx_user_journey_appointment" ON "UserJourney"("appointmentId", "status");
CREATE INDEX "idx_user_journey_current" ON "UserJourney"("currentNodeId", "lastActiveAt");
CREATE INDEX "idx_node_execution_journey" ON "NodeExecution"("journeyId", "executedAt");
CREATE INDEX "idx_patient_profile_lead" ON "PatientProfile"("leadId");
CREATE INDEX "idx_tree_experiment_clinic" ON "TreeExperiment"("clinicId", "status");
+378
View File
@@ -0,0 +1,378 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Clinic {
id String @id @default(cuid())
name String
slug String @unique
email String @unique
passwordHash String?
phone String?
clinicType String @default("Medical Clinic")
wahaSessionId String?
wahaStatus String @default("disconnected")
wahaLastChecked DateTime?
timezone String @default("Asia/Riyadh")
quietHoursStart Int @default(22)
quietHoursEnd Int @default(8)
plan String @default("free")
planExpiresAt DateTime?
trialEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions AppSession[]
appointments Appointment[]
auditLogs AuditLog[]
cronLogs CronLog[]
leads Lead[]
patientNotes PatientNote[]
messages MessageLog[]
oauthAccounts OAuthAccount[]
patientProfiles PatientProfile[]
reminders ReminderTemplate[]
reminderTrees ReminderTree[]
treatments Treatment[]
treeExperiments TreeExperiment[]
campaigns Campaign[]
staff Staff[]
}
model OAuthAccount {
id String @id @default(cuid())
clinicId String
provider String
providerId String
email String?
name String?
avatarUrl String?
accessToken String?
refreshToken String?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@unique([provider, providerId])
@@index([clinicId])
@@index([provider, email])
}
model AppSession {
id String @id @default(cuid())
clinicId String
token String @unique
userAgent String?
ipAddress String?
expiresAt DateTime
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@index([clinicId])
@@index([token])
@@index([expiresAt])
}
model AuditLog {
id String @id @default(cuid())
clinicId String
action String
actor String?
resource String?
metadata String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@index([clinicId, createdAt])
@@index([action, createdAt])
}
model Treatment {
id String @id @default(cuid())
clinicId String
name String
duration Int @default(30)
price Float?
prepInstructions String?
aftercareInstructions String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
appointments Appointment[]
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
}
model Lead {
id String @id @default(cuid())
clinicId String
name String
phone String
email String?
source String @default("manual")
status String @default("new")
treatmentInterest String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
appointments Appointment[]
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
patientNotes PatientNote[]
patientProfiles PatientProfile[]
}
model Appointment {
id String @id @default(cuid())
clinicId String
leadId String
treatmentId String
dateTime DateTime
status String @default("scheduled")
confirmToken String?
confirmedAt DateTime?
arrivedAt DateTime?
completedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
messages MessageLog[]
scheduledReminders ScheduledReminder[]
userJourneys UserJourney[]
}
model ReminderTemplate {
id String @id @default(cuid())
clinicId String?
trigger String
offsetMinutes Int
message String
isActive Boolean @default(true)
sortOrder Int @default(0)
clinic Clinic? @relation(fields: [clinicId], references: [id], onDelete: Cascade)
scheduledReminders ScheduledReminder[]
}
model ScheduledReminder {
id String @id @default(cuid())
appointmentId String
templateId String
scheduledFor DateTime
status String @default("pending")
sentAt DateTime?
failureReason String?
retryCount Int @default(0)
nextRetryAt DateTime?
template ReminderTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
@@index([status, scheduledFor])
}
model MessageLog {
id String @id @default(cuid())
clinicId String
appointmentId String?
phone String
direction String @default("outgoing")
message String
status String @default("queued")
wahaMessageId String?
sentAt DateTime?
deliveredAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
appointment Appointment? @relation(fields: [appointmentId], references: [id])
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@index([wahaMessageId])
}
model CronLog {
id String @id @default(cuid())
clinicId String?
type String
sent Int @default(0)
skipped Int @default(0)
failed Int @default(0)
retried Int @default(0)
durationMs Int @default(0)
error String?
ranAt DateTime @default(now())
clinic Clinic? @relation(fields: [clinicId], references: [id])
@@index([type, ranAt])
}
model ReminderTree {
id String @id @default(cuid())
clinicId String
name String
description String?
triggerType String
isActive Boolean @default(true)
version Int @default(1)
metadata String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
variantFor TreeExperiment[] @relation("VariantTree")
controlFor TreeExperiment[] @relation("ControlTree")
nodes TreeNode[]
userJourneys UserJourney[]
@@index([clinicId, isActive])
}
model TreeNode {
id String @id @default(cuid())
treeId String
parentId String?
name String
nodeType String
content String?
offsetMinutes Int @default(0)
conditions String?
metadata String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
nextNodes NodeExecution[] @relation("NextNode")
executions NodeExecution[]
parent TreeNode? @relation("TreeNodeParent", fields: [parentId], references: [id], onDelete: Cascade)
children TreeNode[] @relation("TreeNodeParent")
tree ReminderTree @relation(fields: [treeId], references: [id], onDelete: Cascade)
userJourneys UserJourney[]
@@index([treeId, sortOrder])
@@index([parentId])
}
model UserJourney {
id String @id @default(cuid())
appointmentId String
treeId String
currentNodeId String?
status String @default("active")
journeyData String?
startedAt DateTime @default(now())
lastActiveAt DateTime @default(now())
completedAt DateTime?
executions NodeExecution[]
currentNode TreeNode? @relation(fields: [currentNodeId], references: [id])
tree ReminderTree @relation(fields: [treeId], references: [id], onDelete: Cascade)
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
@@index([appointmentId, status])
@@index([currentNodeId, lastActiveAt])
}
model NodeExecution {
id String @id @default(cuid())
journeyId String
nodeId String
status String @default("pending")
executedAt DateTime?
response String?
metadata String?
nextNodeId String?
nextNode TreeNode? @relation("NextNode", fields: [nextNodeId], references: [id])
node TreeNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
journey UserJourney @relation(fields: [journeyId], references: [id], onDelete: Cascade)
@@index([journeyId, executedAt])
}
model PatientProfile {
id String @id @default(cuid())
leadId String
clinicId String
responseStyle String @default("standard")
preferredTime String?
engagementScore Float @default(50.0)
noShowRisk Float @default(50.0)
conversationTone String @default("neutral")
languagePreference String @default("auto")
lastInteractionAt DateTime?
profileData String?
updatedAt DateTime @default(now()) @updatedAt
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
@@unique([leadId, clinicId])
@@index([leadId])
}
model TreeExperiment {
id String @id @default(cuid())
clinicId String
name String
description String?
controlTreeId String
variantTreeId String
trafficSplit Float @default(50.0)
status String @default("draft")
startDate DateTime?
endDate DateTime?
metrics String?
results String?
createdAt DateTime @default(now())
variantTree ReminderTree @relation("VariantTree", fields: [variantTreeId], references: [id], onDelete: Cascade)
controlTree ReminderTree @relation("ControlTree", fields: [controlTreeId], references: [id], onDelete: Cascade)
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@index([clinicId, status])
}
model PatientNote {
id String @id @default(cuid())
leadId String
clinicId String
type String @default("note")
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@index([leadId])
}
model Campaign {
id String @id @default(cuid())
clinicId String
name String
message String
targetType String
targetValue String?
sentCount Int @default(0)
status String @default("sent")
createdAt DateTime @default(now())
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@index([clinicId, createdAt])
}
model Staff {
id String @id @default(cuid())
clinicId String
name String
email String
passwordHash String
role String @default("receptionist")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clinic Clinic @relation(fields: [clinicId], references: [id], onDelete: Cascade)
@@unique([clinicId, email])
@@index([clinicId])
}