echos-ocr/prisma/schema.prisma
Randall Stillwell f53b08f99f Add dynamic fields, people directory, analytics, security hardening, and UX polish
Phase 1 - Security & Bug Fixes:
- Add requireApiAuth helper and protect all 25 unprotected API routes
- Add org-tenant scoping to all card, job, stats, and notification queries
- Fix SSRF in ai-test, mask secrets in settings API, fix middleware bypass
- Fix cards pagination routing, stat filter sync, drag-drop file passing
- Add PUT /api/auth/me for profile persistence, stuck job recovery
- Fix email watcher MIME type detection

Phase 2 - Dynamic Fields & Digital Survey:
- Add FormTemplate, FormField, Person, PasswordResetToken models to schema
- Add fieldData, formTemplateId, firstName, lastName, personId to ResponseCard
- Build FormTemplate CRUD API with field management and org scoping
- Build Form Builder UI with field ordering, type config, and section management
- Refactor card detail page to render fields dynamically from templates
- Add dynamic OCR prompt/schema generation from template fields
- Build public survey page at /s/[orgSlug]/[formSlug] with branding
- Add QR code generation API and share section component

Phase 3 - People & Analytics:
- Build People CRUD API with merge and batch auto-link endpoints
- Build People list and detail pages with search, merge dialog
- Add auto-link logic in OCR completion to match/create Person records
- Add /api/stats/trends endpoint with time series and team activity
- Build Reports page with Recharts (area charts, bar charts, pipeline)
- Upgrade dashboard with sparklines and People stat card

Phase 4 - UX Polish:
- Replace silent error handling with toast notifications across all pages
- Add loading skeletons, differentiated empty states
- Add ARIA labels, skip-to-content link, accessible column toggle
- Add forgot password flow, Cmd+K command palette, Collection Days pages
- Unify Echo branding and theme toggle consistency

Made-with: Cursor
2026-04-16 23:29:26 -05:00

484 lines
15 KiB
Text

generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ─── Auth.js tables ──────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
hashedPassword String?
username String?
displayName String?
avatarUrl String @default("")
role String @default("viewer")
activeOrgId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
memberships OrgMember[]
assignedCards ResponseCard[] @relation("AssignedCards")
assignorCards ResponseCard[] @relation("AssignorCards")
reviewedCards ResponseCard[] @relation("ReviewedCards")
activityLogs ActivityLog[]
notifications Notification[]
authentikUid String? @unique
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─── Organization / Multi-tenancy ────────────────────────────
model Organization {
id String @id @default(cuid())
name String
slug String @unique
type String @default("church")
timezone String @default("America/Chicago")
settings Json?
allowedDomains String[] @default([])
onboardingComplete Boolean @default(false)
onboardingStep Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations Location[]
members OrgMember[]
invitations Invitation[]
apiKeys ApiKey[]
cards ResponseCard[]
jobs ProcessingJob[]
integrations Integration[]
formTemplates FormTemplate[]
persons Person[]
}
model OrgMember {
id String @id @default(cuid())
userId String
organizationId String
role String @default("viewer")
dismissedHints Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, organizationId])
}
model Location {
id String @id @default(cuid())
organizationId String
name String
address String?
timezone String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
collectionDays CollectionDay[]
cards ResponseCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CollectionDay {
id String @id @default(cuid())
locationId String
name String
description String?
rrule String?
dayOfWeek Int?
timeStart String?
timeEnd String?
isRecurring Boolean @default(true)
date DateTime?
isActive Boolean @default(true)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
cards ResponseCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Form Templates (Dynamic Fields) ────────────────────────
model FormTemplate {
id String @id @default(cuid())
organizationId String
name String
slug String
description String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
version Int @default(1)
branding Json?
settings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
fields FormField[]
cards ResponseCard[]
@@unique([organizationId, slug])
@@index([organizationId])
}
model FormField {
id String @id @default(cuid())
formTemplateId String
key String
label String
type String
section String @default("personal")
required Boolean @default(false)
removable Boolean @default(true)
sortOrder Int @default(0)
options Json?
placeholder String?
helpText String?
validation Json?
visibleOnCard Boolean @default(true)
visibleOnSurvey Boolean @default(true)
isCore Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
formTemplate FormTemplate @relation(fields: [formTemplateId], references: [id], onDelete: Cascade)
@@unique([formTemplateId, key])
@@index([formTemplateId, sortOrder])
}
// ─── People (Contact Directory) ─────────────────────────────
model Person {
id String @id @default(cuid())
organizationId String
firstName String
lastName String
email String?
cellPhone String?
fieldData Json?
mergedIntoId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
cards ResponseCard[]
mergedInto Person? @relation("PersonMerge", fields: [mergedIntoId], references: [id])
mergedFrom Person[] @relation("PersonMerge")
@@index([organizationId])
@@index([email])
@@index([organizationId, firstName, lastName])
@@index([mergedIntoId])
}
// ─── Integrations ────────────────────────────────────────────
model Integration {
id String @id @default(cuid())
organizationId String
provider String
name String
enabled Boolean @default(false)
config Json
fieldMapping Json?
syncDirection String @default("push")
triggerEvents Json?
lastSyncAt DateTime?
lastSyncStatus String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([organizationId, provider, name])
@@index([organizationId, provider])
}
// ─── Invitations ─────────────────────────────────────────────
model Invitation {
id String @id @default(cuid())
email String
role String @default("viewer")
organizationId String
invitedById String
token String @unique @default(cuid())
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id])
}
// ─── Password Reset ─────────────────────────────────────────
model PasswordResetToken {
id String @id @default(cuid())
email String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
@@index([email])
}
// ─── System config ───────────────────────────────────────────
model SystemConfig {
id String @id @default("singleton")
isSetupComplete Boolean @default(false)
setupStep Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Core application models ─────────────────────────────────
model ResponseCard {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
firstName String?
lastName String?
name String?
gender String?
dateOfBirth String?
maritalStatus String?
maritalStatusOther String?
visitType String?
cellPhone String?
homePhone String?
email String?
address String?
aptNumber String?
city String?
state String?
zip String?
prayerRequests String?
prayerForTeam Boolean @default(false)
prayerConfidential Boolean @default(false)
messageTopics Json?
messageTopicsOther String?
nextStep Json?
attendanceDuration String?
campusPreference Json?
campusPreferenceOther String?
howHeard Json?
howHeardOther String?
serviceAttended String?
followUp String?
notes String?
serviceTime String?
planningCenter String?
iSaidYesBookSent Boolean @default(false)
ftGuestLetterSent Boolean @default(false)
firstTimeGuestDate DateTime?
salvationDate DateTime?
assignedToId String?
assignedById String?
assignedAt DateTime?
reviewedById String?
reviewedAt DateTime?
reviewNotes String?
assignedTo User? @relation("AssignedCards", fields: [assignedToId], references: [id])
assignedBy User? @relation("AssignorCards", fields: [assignedById], references: [id])
reviewedBy User? @relation("ReviewedCards", fields: [reviewedById], references: [id])
sourceFile String?
frontImagePath String?
backImagePath String?
ocrStatus String @default("pending")
reviewStatus String @default("unreviewed")
ocrConfidence Float?
ocrError String?
rawOcrResponse Json?
mondayItemId String?
fieldData Json?
formTemplateId String?
personId String?
submissionSource String?
organizationId String?
locationId String?
collectionDayId String?
collectionDate DateTime?
organization Organization? @relation(fields: [organizationId], references: [id])
location Location? @relation(fields: [locationId], references: [id])
collectionDay CollectionDay? @relation(fields: [collectionDayId], references: [id])
formTemplate FormTemplate? @relation(fields: [formTemplateId], references: [id])
person Person? @relation(fields: [personId], references: [id])
@@index([ocrStatus])
@@index([reviewStatus])
@@index([name])
@@index([firstName, lastName])
@@index([createdAt])
@@index([mondayItemId])
@@index([assignedToId])
@@index([reviewedById])
@@index([organizationId])
@@index([locationId])
@@index([collectionDayId])
@@index([formTemplateId])
@@index([personId])
}
model ProcessingJob {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fileName String
filePath String
status String @default("queued")
totalPages Int @default(0)
processed Int @default(0)
error String?
cardIds Json?
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
@@index([status])
}
model AppSettings {
id String @id @default("singleton")
ollamaUrl String @default("http://192.168.68.108:11434")
model String @default("llava:7b")
watchDir String @default("")
watching Boolean @default(false)
sourceRetentionDays Int @default(30)
imageRetentionDays Int @default(180)
aiProvider String @default("gateway")
aiModel String @default("")
mondayApiToken String @default("")
mondayBoardId String @default("")
mondayEnabled Boolean @default(false)
mondayColumnMap Json?
mondayWebhookId String @default("")
mondayWebhookUrl String @default("")
webhookUrl String @default("")
webhookSecret String @default("")
webhookEnabled Boolean @default(false)
webhookEvents Json?
emailImapHost String @default("imap.dreamhost.com")
emailImapPort Int @default(993)
emailImapUser String @default("echo-ocr@stillwell.cloud")
emailImapPass String @default("")
emailImapTls Boolean @default(true)
emailFolder String @default("INBOX")
emailWatching Boolean @default(false)
emailProcessed String @default("mark_read")
emailProcessedFolder String @default("Processed")
ftpEnabled Boolean @default(false)
ftpHost String @default("")
ftpPort Int @default(21)
ftpUser String @default("")
ftpPass String @default("")
ftpTls Boolean @default(true)
ftpIncomingDir String @default("/incoming")
ftpProcessedDir String @default("/processed")
}
model ActivityLog {
id String @id @default(cuid())
createdAt DateTime @default(now())
cardId String?
action String
source String
summary String
changes Json?
userId String?
user User? @relation(fields: [userId], references: [id])
@@index([cardId, createdAt])
}
model Notification {
id String @id @default(cuid())
createdAt DateTime @default(now())
read Boolean @default(false)
dismissed Boolean @default(false)
type String
title String
message String
cardId String?
actionUrl String?
meta Json?
userId String?
user User? @relation(fields: [userId], references: [id])
@@index([read, dismissed, createdAt])
@@index([cardId])
@@index([userId])
}
model ApiKey {
id String @id @default(cuid())
name String
hashedKey String @unique
prefix String
organizationId String
permissions Json
lastUsedAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id])
}