compassmock/scripts/seed-demo.ts
Nicholai ad2f0c0b9c
feat(security): add multi-tenancy isolation and demo mode (#90)
Add org-scoped data isolation across all server actions to
prevent cross-org data leakage. Add read-only demo mode with
mutation guards on all write endpoints.

Multi-tenancy:
- org filter on executeDashboardQueries (all query types)
- org boundary checks on getChannel, joinChannel
- searchMentionableUsers derives org from session
- getConversationUsage scoped to user, not org-wide for admins
- organizations table, members, org switcher component

Demo mode:
- /demo route sets strict sameSite cookie
- isDemoUser guards on all mutation server actions
- demo banner, CTA dialog, and gate components
- seed script for demo org data

Also: exclude scripts/ from tsconfig (fixes build), add
multi-tenancy architecture documentation.

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-15 22:05:12 -07:00

285 lines
10 KiB
TypeScript

#!/usr/bin/env bun
/**
* Idempotent seed script for demo organization data.
*
* Creates "Meridian Group" (demo org) with:
* - 3 projects, 5 customers, 5 vendors
* - 23 schedule tasks per project (69 total)
* - 3 channels with 25 messages
*
* Safe to re-run: exits early if demo org already exists.
*/
import { Database } from "bun:sqlite"
import { resolve, join } from "path"
import { randomUUID } from "crypto"
import { existsSync, readdirSync } from "fs"
const DEMO_ORG_ID = "demo-org-meridian"
const DEMO_USER_ID = "demo-user-001"
function findDatabase(): string {
const dbDir = resolve(
process.cwd(),
".wrangler/state/v3/d1/miniflare-D1DatabaseObject"
)
if (!existsSync(dbDir)) {
console.error(`Database directory not found: ${dbDir}`)
console.error("Run 'bun dev' first to create the local database.")
process.exit(1)
}
const files = readdirSync(dbDir)
const sqliteFile = files.find((f: string) => f.endsWith(".sqlite"))
if (!sqliteFile) {
console.error(`No .sqlite file found in ${dbDir}`)
process.exit(1)
}
return join(dbDir, sqliteFile)
}
function seed(dbPath: string) {
const db = new Database(dbPath)
db.run("PRAGMA journal_mode = WAL")
const now = new Date().toISOString()
// idempotency check
const existingOrg = db
.prepare("SELECT id FROM organizations WHERE id = ?")
.get(DEMO_ORG_ID)
if (existingOrg) {
console.log("Demo org already exists, skipping seed.")
db.close()
return
}
console.log("Seeding demo data...\n")
const tx = db.transaction(() => {
// 1. demo organization
db.prepare(
`INSERT INTO organizations (id, name, slug, type, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?)`
).run(DEMO_ORG_ID, "Meridian Group", "meridian-demo", "demo", now, now)
console.log("1. Created demo organization")
// 2. demo user
db.prepare(
`INSERT OR IGNORE INTO users (id, email, first_name, last_name, display_name, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`
).run(DEMO_USER_ID, "demo@compass.build", "Demo", "User", "Demo User", "admin", now, now)
console.log("2. Created demo user")
// 3. link user to org
db.prepare(
`INSERT INTO organization_members (id, organization_id, user_id, role, joined_at)
VALUES (?, ?, ?, ?, ?)`
).run(randomUUID(), DEMO_ORG_ID, DEMO_USER_ID, "admin", now)
console.log("3. Linked demo user to organization")
// 4. projects
const projects = [
{ id: randomUUID(), name: "Riverside Tower" },
{ id: randomUUID(), name: "Harbor Bridge Rehabilitation" },
{ id: randomUUID(), name: "Downtown Transit Hub" },
]
for (const p of projects) {
db.prepare(
`INSERT INTO projects (id, name, status, organization_id, created_at)
VALUES (?, ?, ?, ?, ?)`
).run(p.id, p.name, "active", DEMO_ORG_ID, now)
}
console.log("4. Created 3 projects")
// 5. customers
const customers = [
"Metropolitan Construction Corp",
"Riverside Development LLC",
"Harbor City Ventures",
"Transit Authority Partners",
"Downtown Realty Group",
]
for (const name of customers) {
db.prepare(
`INSERT INTO customers (id, name, organization_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`
).run(randomUUID(), name, DEMO_ORG_ID, now, now)
}
console.log("5. Created 5 customers")
// 6. vendors
const vendors = [
"Ace Steel & Fabrication",
"Premier Concrete Supply",
"ElectroTech Solutions",
"Harbor HVAC Systems",
"Summit Plumbing & Mechanical",
]
for (const name of vendors) {
db.prepare(
`INSERT INTO vendors (id, name, category, organization_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(randomUUID(), name, "Subcontractor", DEMO_ORG_ID, now, now)
}
console.log("6. Created 5 vendors")
// 7. schedule tasks per project
const taskTemplates = [
{ title: "Site Preparation & Excavation", workdays: 10, phase: "Foundation", pct: 100 },
{ title: "Foundation Formwork", workdays: 7, phase: "Foundation", pct: 100 },
{ title: "Foundation Concrete Pour", workdays: 3, phase: "Foundation", pct: 100 },
{ title: "Foundation Curing", workdays: 14, phase: "Foundation", pct: 100 },
{ title: "Structural Steel Erection", workdays: 20, phase: "Structural", pct: 85 },
{ title: "Concrete Deck Installation", workdays: 15, phase: "Structural", pct: 70 },
{ title: "Exterior Framing", workdays: 12, phase: "Envelope", pct: 60 },
{ title: "Window & Door Installation", workdays: 10, phase: "Envelope", pct: 40 },
{ title: "Roofing Installation", workdays: 8, phase: "Envelope", pct: 30 },
{ title: "Electrical Rough-In", workdays: 15, phase: "MEP", pct: 25 },
{ title: "Plumbing Rough-In", workdays: 12, phase: "MEP", pct: 20 },
{ title: "HVAC Installation", workdays: 18, phase: "MEP", pct: 15 },
{ title: "Fire Sprinkler System", workdays: 10, phase: "MEP", pct: 10 },
{ title: "Drywall Installation", workdays: 14, phase: "Interior", pct: 5 },
{ title: "Interior Painting", workdays: 10, phase: "Interior", pct: 0 },
{ title: "Flooring Installation", workdays: 12, phase: "Interior", pct: 0 },
{ title: "Cabinet & Fixture Installation", workdays: 8, phase: "Interior", pct: 0 },
{ title: "Final Electrical Trim", workdays: 5, phase: "Finishes", pct: 0 },
{ title: "Final Plumbing Fixtures", workdays: 5, phase: "Finishes", pct: 0 },
{ title: "Site Landscaping", workdays: 10, phase: "Site Work", pct: 0 },
{ title: "Parking Lot Paving", workdays: 7, phase: "Site Work", pct: 0 },
{ title: "Final Inspection", workdays: 2, phase: "Closeout", pct: 0 },
{ title: "Punch List Completion", workdays: 5, phase: "Closeout", pct: 0 },
]
let taskCount = 0
for (const project of projects) {
let currentDate = new Date("2025-01-15")
for (const t of taskTemplates) {
const endDate = new Date(currentDate)
endDate.setDate(endDate.getDate() + t.workdays - 1)
const status = t.pct === 100 ? "COMPLETED" : t.pct > 0 ? "IN_PROGRESS" : "PENDING"
db.prepare(
`INSERT INTO schedule_tasks
(id, project_id, title, start_date, workdays, end_date_calculated,
phase, status, percent_complete, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
randomUUID(),
project.id,
t.title,
currentDate.toISOString().split("T")[0],
t.workdays,
endDate.toISOString().split("T")[0],
t.phase,
status,
t.pct,
taskCount,
now,
now
)
currentDate = new Date(endDate)
currentDate.setDate(currentDate.getDate() + 1)
taskCount++
}
}
console.log(`7. Created ${taskCount} schedule tasks`)
// 8. channel category
const categoryId = randomUUID()
db.prepare(
`INSERT INTO channel_categories (id, name, organization_id, position, created_at)
VALUES (?, ?, ?, ?, ?)`
).run(categoryId, "General", DEMO_ORG_ID, 0, now)
// 9. channels with messages
const channelDefs = [
{ name: "general", type: "text", desc: "General discussion" },
{ name: "project-updates", type: "text", desc: "Project status updates" },
{ name: "announcements", type: "announcement", desc: "Team announcements" },
]
const msgTemplates: Record<string, string[]> = {
general: [
"Morning team! Ready to tackle today's tasks.",
"Quick reminder: safety meeting at 2pm today.",
"Has anyone seen the updated foundation drawings?",
"Great progress on the structural steel this week!",
"Lunch truck will be here at noon.",
"Weather looks good for concrete pour tomorrow.",
"Don't forget to sign off on your timesheets.",
"Electrical inspection passed with no issues!",
"New delivery of materials arriving Thursday.",
"Team huddle in 10 minutes.",
],
"project-updates": [
"Riverside Tower: Foundation phase completed ahead of schedule.",
"Harbor Bridge: Steel erection 85% complete, on track.",
"Transit Hub: Envelope work progressing well despite weather delays.",
"All three projects maintaining budget targets this month.",
"Updated schedules uploaded to the system.",
"Client walkthrough scheduled for Friday morning.",
"MEP rough-in starting next week on Riverside Tower.",
"Harbor Bridge inspection results look excellent.",
"Transit Hub: Window installation begins Monday.",
"Monthly progress photos added to project files.",
],
announcements: [
"Welcome to the Compass demo! Explore the platform features.",
"New safety protocols in effect starting next Monday.",
"Quarterly project review meeting scheduled for next Friday.",
"Updated project templates now available in the system.",
"Reminder: Submit all change orders by end of week.",
],
}
let msgCount = 0
for (const ch of channelDefs) {
const channelId = randomUUID()
db.prepare(
`INSERT INTO channels
(id, name, type, description, organization_id, category_id,
is_private, created_by, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)`
).run(channelId, ch.name, ch.type, ch.desc, DEMO_ORG_ID, categoryId, DEMO_USER_ID, now, now)
// add member
db.prepare(
`INSERT INTO channel_members (id, channel_id, user_id, role, notify_level, joined_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(randomUUID(), channelId, DEMO_USER_ID, "owner", "all", now)
// add messages
const msgs = msgTemplates[ch.name] ?? []
for (const content of msgs) {
db.prepare(
`INSERT INTO messages (id, channel_id, user_id, content, reply_count, created_at)
VALUES (?, ?, ?, ?, 0, ?)`
).run(randomUUID(), channelId, DEMO_USER_ID, content, now)
msgCount++
}
}
console.log(`8. Created 3 channels with ${msgCount} messages`)
})
try {
tx()
console.log("\nDemo seed completed successfully.")
} catch (error) {
console.error("\nDemo seed failed:", error)
process.exit(1)
} finally {
db.close()
}
}
const dbPath = findDatabase()
console.log(`Using database: ${dbPath}\n`)
seed(dbPath)