initial install

This commit is contained in:
Chandler Copeland
2026-04-08 12:54:58 -06:00
parent 71cef4d7b6
commit 9117dc4c02
180 changed files with 2554 additions and 545 deletions

View File

@@ -1,21 +1,37 @@
# ============================================================
# REQUIRED — copy this file to .env.production and fill in all values
# Generate random secrets with: openssl rand -base64 32
# ============================================================
# Database
DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require
# For self-hosted docker-compose: use the internal service name "db"
# Example: postgresql://postgres:STRONG_PASSWORD@db:5432/teressa
# (no ?sslmode=require needed for local docker network)
DATABASE_URL=postgresql://postgres:CHANGE_ME@db:5432/teressa
# Authentication
SIGNING_JWT_SECRET=your-jwt-secret-here
AUTH_SECRET=your-auth-secret-here
AGENT_EMAIL=agent@example.com
AGENT_PASSWORD=your-agent-password
# Authentication secrets (generate with: openssl rand -base64 32)
SIGNING_JWT_SECRET=CHANGE_ME
AUTH_SECRET=CHANGE_ME
AUTH_TRUST_HOST=true
# SMTP (email delivery)
# Agent login credentials (what you use to log in to the portal)
AGENT_EMAIL=your@email.com
AGENT_PASSWORD=CHANGE_ME
# SMTP — email delivery for contact form and document notifications
# Resend (recommended): host=smtp.resend.com, port=465, user=resend, pass=re_xxxxxxx
# Gmail: host=smtp.gmail.com, port=587, user=your@gmail.com, pass=app-password
CONTACT_EMAIL_USER=your-smtp-username
CONTACT_EMAIL_PASS=your-smtp-password
CONTACT_SMTP_HOST=smtp.example.com
CONTACT_SMTP_PORT=587 # use 587 STARTTLS; port 465 SSL may be blocked in some Docker environments
CONTACT_SMTP_PORT=587
# OpenAI (AI field placement)
# OpenAI — required for AI-assisted PDF field placement
OPENAI_API_KEY=sk-your-openai-key
# Application
# Application public URL (no trailing slash)
APP_BASE_URL=https://yourdomain.com
AUTH_TRUST_HOST=true
# Internal postgres password — must match DATABASE_URL above
# Only used by the db: service in docker-compose.yml
POSTGRES_PASSWORD=CHANGE_ME

View File

@@ -1 +1,349 @@
@AGENTS.md
# CLAUDE.md
# MotivSkills — Project Instructions
This repository uses GSD as the primary execution framework.
Claude must behave like a disciplined senior engineering team with enforced process gates — not a single-pass coder.
---
# Core Principles
1. **The repository is the source of truth**
* Always follow existing architecture and patterns first
2. **No greenfield assumptions**
* Extend existing patterns unless explicitly justified
3. **Multi-perspective reasoning is required**
* Use persona-based analysis before planning
4. **Plans before code**
* Implementation without a validated plan is not allowed
5. **Consistency is a requirement**
* Matching repo conventions is mandatory, not optional
---
# Execution Workflow (MANDATORY)
For all non-trivial work, Claude MUST follow this sequence:
1. Understand the request
2. Inspect the repository
3. Produce Architecture Summary
4. Run Multi-Agent Research
5. Produce Research Synthesis
6. Run Planning Phase
7. Produce Plan Synthesis
8. Execute
9. Verify
If any step is skipped → STOP and correct before continuing.
---
# Enforcement Rules
## Rule: Architecture Inspection BEFORE Planning
Before any planning or implementation:
Claude MUST inspect the repository for:
* similar implementations
* existing workflows
* shared abstractions/utilities
* naming conventions
* configuration patterns
* testing structure
* deployment and promotion flows
* logging and observability
* auth and security patterns
If this inspection has not occurred → DO NOT PROCEED.
---
## Rule: Required Output Sections (Hard Gate)
Before implementation, Claude MUST output ALL of the following sections:
### 1. Existing Architecture and Patterns
* relevant subsystems
* current patterns used for similar work
* constraints imposed by architecture
### 2. Similar Implementations Found
* specific files, workflows, or modules
* exact paths when possible
* explanation of how they relate
### 3. Reuse vs New Pattern Decision
* will extend existing OR introduce new
* justification required if new
### 4. Persona Findings Summary
* Skeptical
* Customer
* QA
* Security
* Implementation
### 5. Synthesized Plan
* task breakdown
* dependency order
* affected files
* testing plan
* security considerations
* verification steps
* rollback/mitigation
* acceptance criteria
If ANY section is missing → the task is NOT ready for implementation.
---
## Rule: Extend Existing Patterns by Default
If an existing pattern exists:
* it MUST be reused or extended
DO NOT:
* introduce new folder structures
* create new workflow styles
* invent new abstractions unnecessarily
---
## Rule: Deviation Must Be Justified
If introducing a new pattern, Claude MUST explain:
* existing pattern found
* why it is insufficient
* why new approach is better
* long-term consistency impact
* migration implications
No silent divergence allowed.
---
## Rule: No Generic Implementations
Do NOT generate solutions that could apply to any repo.
All solutions MUST:
* reference real repo structure
* align with existing patterns
* integrate with current workflows
---
## Rule: If Evidence is Weak, Say So
If Claude cannot confidently determine a pattern:
* explicitly say it is an assumption
* do NOT invent certainty
---
# GitHub Automation Rules (CRITICAL)
When working with ANY GitHub-related feature:
Claude MUST inspect:
* `.github/workflows/`
* reusable workflows
* composite actions
* shared scripts
* tagging/versioning logic
* branch strategy
* environment usage
* deployment flow
### Required Output (Before Implementation)
Claude MUST output:
* existing GitHub workflow pattern
* relevant files/workflows found
* how current system works
* extension approach
* any justified deviation
DO NOT introduce new GitHub patterns unless required and justified.
---
# Multi-Agent Research Council
Claude MUST simulate the following perspectives:
## Skeptical Engineer
* challenge assumptions
* find hidden complexity
* identify failure modes
## Customer-Oriented Engineer
* evaluate UX and DX
* identify friction
* define acceptance criteria
## QA Engineer
* define test strategy
* identify edge cases
* find regression risks
## Security Engineer
* evaluate auth, secrets, inputs
* identify attack surface
* define safeguards
## Implementation Engineer
* define architecture approach
* identify files and sequencing
* ensure maintainability
---
# Aggregation Rules
## Research Synthesis MUST include:
* problem summary
* current-state findings
* risks (ranked)
* assumptions vs facts
* open questions
* recommended direction
---
## Plan Synthesis MUST include:
* concrete task breakdown
* dependency order
* impacted files/components
* test plan
* security checks
* verification steps
* rollback strategy
* acceptance criteria
---
# Decision Priority Order
When tradeoffs exist:
1. correctness
2. security
3. consistency with repo
4. user impact
5. operability
6. speed
---
# Repository Consistency Requirements
All implementations MUST match:
* file structure
* naming conventions
* abstraction patterns
* workflow design
* dependency handling
* environment handling
* testing strategy
* deployment model
Consistency is mandatory.
---
# Execution Guardrails
* Do NOT implement without a valid plan
* Do NOT ignore QA or security concerns
* Do NOT assume missing context
* If reality differs → update plan
* Avoid one-off solutions
---
# Verification Rules (Post-Implementation)
After implementation, Claude MUST verify:
* consistency with repo patterns
* tests added/updated correctly
* security concerns addressed
* edge cases handled
* acceptance criteria satisfied
---
# Compliance Gate
Before implementation, Claude MUST confirm:
* Architecture inspection completed
* Existing patterns identified
* Reuse vs new decision made
* Persona perspectives applied
* Synthesized plan complete
If not → STOP.
---
# Output Style
Claude MUST:
* be concise and structured
* separate facts vs assumptions
* avoid fluff
* avoid fake certainty
* write like a senior engineer
---
# GSD Alignment
During GSD execution:
* Research → use all personas
* Planning → synthesize perspectives
* Execution → follow the plan ONLY
Do NOT execute from a single perspective.
The synthesized plan is the source of truth.
---

View File

@@ -1,63 +1,198 @@
# Deployment Guide
# Deployment Guide — Self-Hosted (Unraid + Gitea)
## Prerequisites
## Overview
- Git installed on the server
- Docker and Docker Compose installed on the server
This app runs as two Docker containers:
- **app** — Next.js server (port 3000)
- **db** — PostgreSQL 16 (port 5432)
## Step 1: Configure environment
---
Copy the example env file and fill in real values:
## Required Secrets
All of these must be set in `.env.production` before starting.
| Variable | What it is | How to get it |
|---|---|---|
| `DATABASE_URL` | Internal postgres URL | `postgresql://postgres:POSTGRES_PASSWORD@db:5432/teressa` |
| `POSTGRES_PASSWORD` | Postgres superuser password | Generate: `openssl rand -base64 32` |
| `SIGNING_JWT_SECRET` | Signs document signing tokens | Generate: `openssl rand -base64 32` |
| `AUTH_SECRET` | NextAuth session encryption | Generate: `openssl rand -base64 32` |
| `AUTH_TRUST_HOST` | Required for reverse proxy | Set to `true` |
| `AGENT_EMAIL` | Your login email for the portal | Your email address |
| `AGENT_PASSWORD` | Your login password for the portal | Choose a strong password |
| `CONTACT_EMAIL_USER` | SMTP username | See SMTP section below |
| `CONTACT_EMAIL_PASS` | SMTP password/API key | See SMTP section below |
| `CONTACT_SMTP_HOST` | SMTP server hostname | See SMTP section below |
| `CONTACT_SMTP_PORT` | SMTP port | `587` (STARTTLS) or `465` (SSL) |
| `OPENAI_API_KEY` | OpenAI API key for AI field placement | platform.openai.com → API keys |
| `APP_BASE_URL` | Public URL of the app (no trailing slash) | e.g. `https://teressa.yourdomain.com` |
### SMTP Options
- **Resend** (recommended): host=`smtp.resend.com`, port=`465`, user=`resend`, pass=Resend API key
- **Gmail**: host=`smtp.gmail.com`, port=`587`, user=your Gmail, pass=App Password (not your regular password)
---
## Part 1: Push Repo to Gitea
### On your local machine
```bash
cp .env.production.example .env.production
# Add your Gitea instance as a remote
git remote add gitea http://YOUR_UNRAID_IP:3000/YOUR_GITEA_USERNAME/teressa-copeland-homes.git
# Push
git push gitea main
```
Edit `.env.production` and set all 11 variables:
Create the repo in Gitea first (via the Gitea web UI) before pushing.
- `DATABASE_URL` — Neon PostgreSQL connection string
- `SIGNING_JWT_SECRET` — secret for signing JWT tokens
- `AUTH_SECRET` — NextAuth secret
- `AGENT_EMAIL` — agent login email
- `AGENT_PASSWORD` — agent login password
- `CONTACT_EMAIL_USER` — SMTP username
- `CONTACT_EMAIL_PASS` — SMTP password
- `CONTACT_SMTP_HOST` — SMTP host (e.g. `smtp.gmail.com`)
- `CONTACT_SMTP_PORT` — SMTP port (e.g. `465`)
- `OPENAI_API_KEY` — OpenAI API key for AI field placement
- `APP_BASE_URL` — public URL of the app (e.g. `https://teressacopelandhomes.com`)
---
## Step 2: Run database migration
## Part 2: Build and Push Docker Image to Gitea Registry
Run migrations from the project directory on the host (not inside Docker):
Gitea includes a built-in OCI container registry. Replace `YOUR_UNRAID_IP`, `YOUR_GITEA_USERNAME`, and `YOUR_GITEA_PORT` with your values.
```bash
DATABASE_URL=<your-neon-url> npx drizzle-kit migrate
# Log in to Gitea's container registry
docker login YOUR_UNRAID_IP:YOUR_GITEA_PORT
# Build the image (from the project root)
docker build -t YOUR_UNRAID_IP:YOUR_GITEA_PORT/YOUR_GITEA_USERNAME/teressa-copeland-homes:latest .
# Push to Gitea registry
docker push YOUR_UNRAID_IP:YOUR_GITEA_PORT/YOUR_GITEA_USERNAME/teressa-copeland-homes:latest
```
This must complete successfully before starting the container. Migrations are never run inside the Docker image.
> **Gitea registry port** is usually the same as the Gitea web port (e.g. 3000 or 10880 depending on your setup). Check your Gitea container settings in Unraid.
## Step 3: Build and start
> **HTTP registry**: If Gitea is HTTP (not HTTPS), add the registry to Docker's insecure registries. On Unraid, go to **Settings → Docker → Insecure Registries** and add `YOUR_UNRAID_IP:PORT`.
---
## Part 3: Deploy on Unraid
### Option A: Docker Compose (recommended)
Unraid supports docker-compose via the **Compose Manager** community app.
1. Install **Compose Manager** from Community Apps if not already installed
2. SSH into Unraid and create the app directory:
```bash
mkdir -p /mnt/user/appdata/teressa-copeland-homes
cd /mnt/user/appdata/teressa-copeland-homes
```
3. Copy `docker-compose.yml` and `.env.production.example` to this directory:
```bash
cp docker-compose.yml .env.production.example /mnt/user/appdata/teressa-copeland-homes/
```
4. Create `.env.production` from the example and fill in all values:
```bash
cp .env.production.example .env.production
nano .env.production
```
5. Set `APP_IMAGE` to your Gitea registry URL in `.env.production`:
```
APP_IMAGE=YOUR_UNRAID_IP:YOUR_GITEA_PORT/YOUR_GITEA_USERNAME/teressa-copeland-homes:latest
```
6. In Compose Manager, add the compose file and start it
### Option B: Unraid Docker UI (manual containers)
Add two containers via **Docker → Add Container**:
**Container 1 — postgres**
- Repository: `postgres:16-alpine`
- Name: `teressa-db`
- Port: `5432:5432`
- Environment variables:
- `POSTGRES_USER=postgres`
- `POSTGRES_PASSWORD=YOUR_STRONG_PASSWORD`
- `POSTGRES_DB=teressa`
- Path: `/mnt/user/appdata/teressa-db` → `/var/lib/postgresql/data`
**Container 2 — app**
- Repository: `YOUR_UNRAID_IP:YOUR_GITEA_PORT/YOUR_GITEA_USERNAME/teressa-copeland-homes:latest`
- Name: `teressa-app`
- Port: `3000:3000`
- Network: Same as teressa-db (so it can reach `teressa-db` by name, or use Unraid IP)
- Environment variables: All 13 from the table above
- Note: `DATABASE_URL` should use the Unraid host IP or container name instead of `db`
- Path: `/mnt/user/appdata/teressa-uploads` → `/app/uploads`
---
## Part 4: Run Migrations and Seed
These must run **after** the database container is healthy, **before** the app is usable.
### Run migrations
```bash
docker compose up -d --build
# From your local machine (requires node + npx)
DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@YOUR_UNRAID_IP:5432/teressa" npx drizzle-kit migrate
# Or SSH into Unraid and run from the project directory
docker exec -it teressa-app npx drizzle-kit migrate
```
## Step 4: Verify
### Seed agent account + form templates
```bash
curl http://localhost:3000/api/health
# SSH into Unraid
docker exec \
-e DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@db:5432/teressa" \
-e AGENT_EMAIL="your@email.com" \
-e AGENT_PASSWORD="your-password" \
teressa-app npx tsx scripts/seed.ts
# Seed the 140 real estate form templates
docker exec \
-e DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@db:5432/teressa" \
teressa-app npx tsx scripts/seed-forms.ts
```
Expected response: `{"ok":true,"db":"connected"}`
Both seed scripts are idempotent — safe to re-run.
---
## Part 5: Verify
```bash
curl http://YOUR_UNRAID_IP:3000/api/health
# Expected: {"ok":true,"db":"connected"}
```
Then open `http://YOUR_UNRAID_IP:3000` in a browser and log in with `AGENT_EMAIL` / `AGENT_PASSWORD`.
---
## Updating
To deploy a new version:
When you push a new version:
```bash
git pull
# If schema changed, re-run migration first:
DATABASE_URL=<your-neon-url> npx drizzle-kit migrate
docker compose up -d --build
# On local machine — rebuild and push image
docker build -t YOUR_UNRAID_IP:PORT/YOUR_GITEA_USERNAME/teressa-copeland-homes:latest .
docker push YOUR_UNRAID_IP:PORT/YOUR_GITEA_USERNAME/teressa-copeland-homes:latest
# On Unraid — pull new image and restart
docker pull YOUR_UNRAID_IP:PORT/YOUR_GITEA_USERNAME/teressa-copeland-homes:latest
docker restart teressa-app
# If schema changed, run migrations first:
DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@YOUR_UNRAID_IP:5432/teressa" npx drizzle-kit migrate
```
---
## Reverse Proxy (optional)
If you want HTTPS via a domain name, put a reverse proxy in front on port 443:
- **Nginx Proxy Manager** (available in Unraid Community Apps) — easiest GUI option
- **Caddy** — automatic HTTPS with Let's Encrypt
- **Traefik** — label-based routing
Point the proxy at `http://YOUR_UNRAID_IP:3000`. Make sure `APP_BASE_URL` in `.env.production` matches the public HTTPS URL, and ensure `AUTH_TRUST_HOST=true` is set.

View File

@@ -3,10 +3,10 @@ services:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5433:5432"
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: teressa
volumes:
- pgdata:/var/lib/postgresql/data
@@ -17,12 +17,10 @@ services:
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
image: ${APP_IMAGE:-teressa-copeland-homes:latest}
restart: unless-stopped
ports:
- "3001:3000"
- "3000:3000"
env_file:
- .env.production
environment:

View File

@@ -0,0 +1,496 @@
{
"id": "12f39205-4988-4d36-93bb-bed72096c5c0",
"prevId": "9f37640a-48c4-4da2-8c18-a212eaf5b78c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.audit_events": {
"name": "audit_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"document_id": {
"name": "document_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event_type": {
"name": "event_type",
"type": "audit_event_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"audit_events_document_id_documents_id_fk": {
"name": "audit_events_document_id_documents_id_fk",
"tableFrom": "audit_events",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"contacts": {
"name": "contacts",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"property_address": {
"name": "property_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "document_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'Draft'"
},
"sent_at": {
"name": "sent_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"form_template_id": {
"name": "form_template_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"signature_fields": {
"name": "signature_fields",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"text_fill_data": {
"name": "text_fill_data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"assigned_client_id": {
"name": "assigned_client_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"prepared_file_path": {
"name": "prepared_file_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email_addresses": {
"name": "email_addresses",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"signed_file_path": {
"name": "signed_file_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_hash": {
"name": "pdf_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"signed_at": {
"name": "signed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"signers": {
"name": "signers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"completion_triggered_at": {
"name": "completion_triggered_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"documents_client_id_clients_id_fk": {
"name": "documents_client_id_clients_id_fk",
"tableFrom": "documents",
"tableTo": "clients",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"documents_form_template_id_form_templates_id_fk": {
"name": "documents_form_template_id_form_templates_id_fk",
"tableFrom": "documents",
"tableTo": "form_templates",
"columnsFrom": [
"form_template_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.form_templates": {
"name": "form_templates",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"form_templates_filename_unique": {
"name": "form_templates_filename_unique",
"nullsNotDistinct": false,
"columns": [
"filename"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.signing_tokens": {
"name": "signing_tokens",
"schema": "",
"columns": {
"jti": {
"name": "jti",
"type": "text",
"primaryKey": true,
"notNull": true
},
"document_id": {
"name": "document_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"signer_email": {
"name": "signer_email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"signing_tokens_document_id_documents_id_fk": {
"name": "signing_tokens_document_id_documents_id_fk",
"tableFrom": "signing_tokens",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"agent_signature_data": {
"name": "agent_signature_data",
"type": "text",
"primaryKey": false,
"notNull": false
},
"agent_initials_data": {
"name": "agent_initials_data",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.audit_event_type": {
"name": "audit_event_type",
"schema": "public",
"values": [
"document_prepared",
"email_sent",
"link_opened",
"document_viewed",
"signature_submitted",
"pdf_hash_computed"
]
},
"public.document_status": {
"name": "document_status",
"schema": "public",
"values": [
"Draft",
"Sent",
"Viewed",
"Signed"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,567 @@
{
"id": "b839b2b9-21ad-4214-a72a-c0bfebeaa7a1",
"prevId": "12f39205-4988-4d36-93bb-bed72096c5c0",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.audit_events": {
"name": "audit_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"document_id": {
"name": "document_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event_type": {
"name": "event_type",
"type": "audit_event_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"audit_events_document_id_documents_id_fk": {
"name": "audit_events_document_id_documents_id_fk",
"tableFrom": "audit_events",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"contacts": {
"name": "contacts",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"property_address": {
"name": "property_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.document_templates": {
"name": "document_templates",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"form_template_id": {
"name": "form_template_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"signature_fields": {
"name": "signature_fields",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"archived_at": {
"name": "archived_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"document_templates_form_template_id_form_templates_id_fk": {
"name": "document_templates_form_template_id_form_templates_id_fk",
"tableFrom": "document_templates",
"tableTo": "form_templates",
"columnsFrom": [
"form_template_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "document_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'Draft'"
},
"sent_at": {
"name": "sent_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"form_template_id": {
"name": "form_template_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"signature_fields": {
"name": "signature_fields",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"text_fill_data": {
"name": "text_fill_data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"assigned_client_id": {
"name": "assigned_client_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"prepared_file_path": {
"name": "prepared_file_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email_addresses": {
"name": "email_addresses",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"signed_file_path": {
"name": "signed_file_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_hash": {
"name": "pdf_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"signed_at": {
"name": "signed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"signers": {
"name": "signers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"completion_triggered_at": {
"name": "completion_triggered_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"documents_client_id_clients_id_fk": {
"name": "documents_client_id_clients_id_fk",
"tableFrom": "documents",
"tableTo": "clients",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"documents_form_template_id_form_templates_id_fk": {
"name": "documents_form_template_id_form_templates_id_fk",
"tableFrom": "documents",
"tableTo": "form_templates",
"columnsFrom": [
"form_template_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.form_templates": {
"name": "form_templates",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"form_templates_filename_unique": {
"name": "form_templates_filename_unique",
"nullsNotDistinct": false,
"columns": [
"filename"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.signing_tokens": {
"name": "signing_tokens",
"schema": "",
"columns": {
"jti": {
"name": "jti",
"type": "text",
"primaryKey": true,
"notNull": true
},
"document_id": {
"name": "document_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"signer_email": {
"name": "signer_email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"signing_tokens_document_id_documents_id_fk": {
"name": "signing_tokens_document_id_documents_id_fk",
"tableFrom": "signing_tokens",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"agent_signature_data": {
"name": "agent_signature_data",
"type": "text",
"primaryKey": false,
"notNull": false
},
"agent_initials_data": {
"name": "agent_initials_data",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.audit_event_type": {
"name": "audit_event_type",
"schema": "public",
"values": [
"document_prepared",
"email_sent",
"link_opened",
"document_viewed",
"signature_submitted",
"pdf_hash_computed"
]
},
"public.document_status": {
"name": "document_status",
"schema": "public",
"values": [
"Draft",
"Sent",
"Viewed",
"Signed"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -78,6 +78,20 @@
"when": 1775250937545,
"tag": "0010_sharp_archangel",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1775259146259,
"tag": "0011_common_mystique",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1775499305623,
"tag": "0012_ancient_blue_shield",
"breakpoints": true
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -13,6 +13,8 @@
import { chromium } from 'playwright';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
import AdmZip from 'adm-zip';
import { config } from 'dotenv';
config({ path: path.resolve(process.cwd(), '.env.local') });
@@ -221,6 +223,17 @@ async function handleNRDSAuth(page: import('playwright').Page) {
}
}
function extractFormNames(bodyText: string): string[] {
const lines = bodyText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const formNames: string[] = [];
for (let i = 0; i < lines.length; i++) {
if (lines[i] === 'Add' && i > 0 && lines[i - 1] !== 'Add' && lines[i - 1].length > 3) {
formNames.push(lines[i - 1]);
}
}
return formNames;
}
async function downloadFormsInView(
page: import('playwright').Page,
context: import('playwright').BrowserContext,
@@ -228,19 +241,35 @@ async function downloadFormsInView(
downloaded: string[],
failed: string[]
) {
// Flow: click form name → preview opens → click Download button → save file
// Flow: scroll to load all forms, then click form name → preview Download button → save
// Extract form names from the page body text — the list renders as "Name\nAdd\nName\nAdd..."
const bodyText = await page.locator('body').innerText().catch(() => '');
const lines = bodyText.split('\n').map(l => l.trim()).filter(l => l.length > 3);
const formNames: string[] = [];
for (let i = 0; i < lines.length; i++) {
if (lines[i] === 'Add' && i > 0 && lines[i - 1] !== 'Add' && lines[i - 1].length > 3) {
formNames.push(lines[i - 1]);
// Scroll down repeatedly to trigger infinite scroll, collecting all form names
const allNames = new Set<string>();
let prevCount = 0;
let stallRounds = 0;
while (stallRounds < 5) {
// Scroll both window and any inner scrollable container to handle virtualized lists
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
const scrollable = document.querySelector('[class*="scroll"], [class*="list"], main, [role="main"], .overflow-auto, .overflow-y-auto');
if (scrollable) scrollable.scrollTop = scrollable.scrollHeight;
});
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText().catch(() => '');
for (const n of extractFormNames(bodyText)) allNames.add(n);
if (allNames.size === prevCount) {
stallRounds++;
} else {
stallRounds = 0;
prevCount = allNames.size;
process.stdout.write(` Loaded ${allNames.size} forms so far...\n`);
}
}
const names = [...new Set(formNames)];
// Scroll back to top before clicking
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(500);
const names = [...allNames];
console.log(` Found ${names.length} forms to download`);
if (names.length === 0) {
await page.screenshot({ path: `scripts/debug-no-forms-${Date.now()}.png` });
@@ -285,7 +314,9 @@ async function downloadFormsInView(
page.waitForEvent('download', { timeout: 20_000 }),
downloadBtn.click(),
]);
await download.saveAs(destPath);
const tmpPath = path.join(tmpdir(), `skyslope-${Date.now()}.tmp`);
await download.saveAs(tmpPath);
await savePdf(tmpPath, destPath);
process.stdout.write(`${sanitized}.pdf\n`);
downloaded.push(sanitized);
} catch (err) {
@@ -379,6 +410,21 @@ async function interceptPdfOnClick(
});
}
/** If the downloaded file is a ZIP, extract the first PDF inside; otherwise move as-is. */
async function savePdf(tmpPath: string, destPath: string) {
const buf = await fs.readFile(tmpPath);
const isPk = buf[0] === 0x50 && buf[1] === 0x4b; // PK magic bytes = ZIP
if (isPk) {
const zip = new AdmZip(buf);
const entry = zip.getEntries().find(e => e.entryName.toLowerCase().endsWith('.pdf'));
if (!entry) throw new Error('ZIP contained no PDF entry');
await fs.writeFile(destPath, entry.getData());
} else {
await fs.rename(tmpPath, destPath);
}
await fs.unlink(tmpPath).catch(() => {}); // clean up tmp if rename didn't move it
}
main().catch(err => {
console.error('Fatal:', err.message);
process.exit(1);

View File

@@ -1,9 +1,8 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { users, clients, documents } from "../src/lib/db/schema";
import { users } from "../src/lib/db/schema";
import bcrypt from "bcryptjs";
import { inArray } from "drizzle-orm";
const client = postgres(process.env.DATABASE_URL!);
const db = drizzle({ client });
@@ -22,35 +21,6 @@ async function seed() {
console.log(`Seeded agent account: ${email}`);
// Seed clients
await db.insert(clients).values([
{ name: "Sarah Johnson", email: "sarah.j@example.com" },
{ name: "Mike Torres", email: "m.torres@example.com" },
]).onConflictDoNothing();
console.log("Seeded clients: Sarah Johnson, Mike Torres");
// Query back seeded clients to get their IDs
const [sarah, mike] = await db
.select({ id: clients.id })
.from(clients)
.where(inArray(clients.email, ["sarah.j@example.com", "m.torres@example.com"]))
.orderBy(clients.createdAt);
// Seed placeholder documents
if (sarah && mike) {
await db.insert(documents).values([
{ name: "Purchase Agreement - 842 Maple Dr", clientId: sarah.id, status: "Signed", sentAt: new Date("2026-02-15") },
{ name: "Seller Disclosure - 842 Maple Dr", clientId: sarah.id, status: "Signed", sentAt: new Date("2026-02-14") },
{ name: "Buyer Rep Agreement", clientId: mike.id, status: "Sent", sentAt: new Date("2026-03-10") },
{ name: "Purchase Agreement - 1205 Oak Ave", clientId: mike.id, status: "Draft", sentAt: null },
]).onConflictDoNothing();
console.log("Seeded 4 placeholder documents");
} else {
console.log("Skipping documents seed — clients not found");
}
process.exit(0);
}

Some files were not shown because too many files have changed in this diff Show More