# InteriorScan — Data Model ## Entity Relationship ``` users ──< team_members >── teams ──< projects ──< photos │ │ │ └── walkthroughs │ └── subscriptions projects ──< processing_jobs ``` ## Drizzle Schema Definition ```typescript // packages/db/src/schema.ts import { pgTable, text, timestamp, integer, jsonb, boolean, pgEnum } from 'drizzle-orm/pg-core'; import { cuid2 } from './custom-types'; // branded cuid2 ID type // ── Enums ────────────────────────────────────────── export const projectStatusEnum = pgEnum('project_status', [ 'draft', // created, no photos yet 'uploading', // photos being uploaded 'queued', // queued for processing 'processing', // COLMAP/OpenMVS pipeline running 'complete', // walkthrough ready 'failed', // processing error ]); export const processingStageEnum = pgEnum('processing_stage', [ 'feature_extraction', // SIFT/ORB features 'feature_matching', // Correspondence search 'sfm', // Structure from Motion → sparse cloud 'mvs', // Multi-View Stereo → dense cloud 'meshing', // Mesh generation 'texturing', // UV mapping + texturing 'tiling', // LOD + Draco compression 'complete', ]); export const subscriptionPlanEnum = pgEnum('subscription_plan', [ 'free', 'pro', 'enterprise', ]); export const teamRoleEnum = pgEnum('team_role', [ 'owner', 'admin', 'member', 'viewer', ]); // ── Users (better-auth managed) ───────────────────── export const users = pgTable('users', { id: cuid2('id').primaryKey(), email: text('email').notNull().unique(), name: text('name').notNull(), emailVerified: boolean('email_verified').default(false), image: text('image'), // avatar URL createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); // ── Teams & Membership ────────────────────────────── export const teams = pgTable('teams', { id: cuid2('id').primaryKey(), name: text('name').notNull(), slug: text('slug').notNull().unique(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); export const teamMembers = pgTable('team_members', { id: cuid2('id').primaryKey(), teamId: cuid2('team_id').references(() => teams.id).notNull(), userId: cuid2('user_id').references(() => users.id).notNull(), role: teamRoleEnum('role').default('member').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }); // ── Projects ──────────────────────────────────────── export const projects = pgTable('projects', { id: cuid2('id').primaryKey(), teamId: cuid2('team_id').references(() => teams.id).notNull(), name: text('name').notNull(), address: text('address'), // property address description: text('description'), status: projectStatusEnum('status').default('draft').notNull(), photoCount: integer('photo_count').default(0), captureDevice: text('capture_device'), // 'iphone-lidar', 'android', 'theta-z1', 'unknown' processingProgress: integer('processing_progress').default(0), // 0-100 createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); // ── Photos ────────────────────────────────────────── export const photos = pgTable('photos', { id: cuid2('id').primaryKey(), projectId: cuid2('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(), originalKey: text('original_key').notNull(), // S3/storage key for original thumbnailKey: text('thumbnail_key'), // processed thumbnail key perceptualHash: text('perceptual_hash'), // for deduplication width: integer('width'), height: integer('height'), fileSizeBytes: integer('file_size_bytes'), exifData: jsonb('exif_data'), // GPS, camera model, focal length isDuplicate: boolean('is_duplicate').default(false), createdAt: timestamp('created_at').defaultNow().notNull(), }); // ── Walkthroughs ──────────────────────────────────── export const walkthroughs = pgTable('walkthroughs', { id: cuid2('id').primaryKey(), projectId: cuid2('project_id').references(() => projects.id).notNull(), meshKey: text('mesh_key').notNull(), // tiled mesh storage key textureKey: text('texture_key').notNull(), // texture atlas storage key navmeshKey: text('navmesh_key'), // navmesh for click-to-walk pointCloudKey: text('point_cloud_key'), // E57/LAS point cloud (optional) thumbnailKey: text('thumbnail_key'), // preview image bounds: jsonb('bounds'), // { min: [x,y,z], max: [x,y,z] } stats: jsonb('stats'), // { vertices: N, faces: N, rooms: N } isPublic: boolean('is_public').default(true), slug: text('slug').notNull().unique(), // for shareable URLs createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); // ── Processing Jobs ───────────────────────────────── export const processingJobs = pgTable('processing_jobs', { id: cuid2('id').primaryKey(), projectId: cuid2('project_id').references(() => projects.id).notNull(), stage: processingStageEnum('stage').default('feature_extraction').notNull(), progress: integer('progress').default(0), // 0-100 per stage error: text('error'), // error message if failed startedAt: timestamp('started_at'), completedAt: timestamp('completed_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }); // ── Subscriptions ─────────────────────────────────── export const subscriptions = pgTable('subscriptions', { id: cuid2('id').primaryKey(), teamId: cuid2('team_id').references(() => teams.id).notNull(), plan: subscriptionPlanEnum('plan').default('free').notNull(), stripeCustomerId: text('stripe_customer_id'), stripePriceId: text('stripe_price_id'), stripeSubscriptionId: text('stripe_subscription_id'), monthlyQuota: integer('monthly_quota').default(3), // Free=3, Pro=25, Enterprise=unlimited monthlyUsage: integer('monthly_usage').default(0), currentPeriodStart: timestamp('current_period_start'), currentPeriodEnd: timestamp('current_period_end'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); // ── Sessions (better-auth managed) ────────────────── export const sessions = pgTable('sessions', { id: cuid2('id').primaryKey(), userId: cuid2('user_id').references(() => users.id).notNull(), token: text('token').notNull().unique(), expiresAt: timestamp('expires_at').notNull(), ipAddress: text('ip_address'), userAgent: text('user_agent'), createdAt: timestamp('created_at').defaultNow().notNull(), }); ``` ## Indexes ```sql CREATE INDEX idx_projects_team_id ON projects(team_id); CREATE INDEX idx_projects_status ON projects(status); CREATE INDEX idx_photos_project_id ON photos(project_id); CREATE INDEX idx_photos_perceptual_hash ON photos(perceptual_hash); CREATE INDEX idx_walkthroughs_project_id ON walkthroughs(project_id); CREATE INDEX idx_walkthroughs_slug ON walkthroughs(slug); CREATE INDEX idx_processing_jobs_project_id ON processing_jobs(project_id); CREATE INDEX idx_team_members_user_id ON team_members(user_id); CREATE INDEX idx_team_members_team_id ON team_members(team_id); CREATE INDEX idx_subscriptions_team_id ON subscriptions(team_id); ``` ## Constraints | Rule | Level | Implementation | |------|-------|---------------| | User email unique | DB | `users.email UNIQUE` | | Team slug unique | DB | `teams.slug UNIQUE` | | Walkthrough slug unique | DB | `walkthroughs.slug UNIQUE` | | Project belongs to team | DB | `projects.team_id FK → teams.id` | | Photo belongs to project | DB | `photos.project_id FK → projects.id CASCADE` | | Max 500 photos per project | App | Validate in Zod schema + upload endpoint | | Monthly quota enforcement | App | Middleware checks `subscriptions.monthlyUsage < monthlyQuota` | | Photo deduplication | App | Perceptual hash comparison before insert |