Files
interiorscan/.specify/specs/001-interiorscan-mvp/data-model.md
T
hermes be3221e93d feat: add catastro SSOT integration, 3DGS hybrid pipeline, casa demo v3
- SPEC: add User Story 0 (Property Setup via Catastro) as P1 entry
- SPEC: add catastro_data and catastro_imports tables to data model
- PLAN: add Phase 2A (Catastro Integration) with 8 tasks
- AGENTS.md: add 3 key decisions (Catastro SSOT, 3DGS hybrid, plan inputs)
- Demo v3: Spanish apartment (C/ Altabix 24, 3ºB, Elche), 7 rooms,
  upload panel for catastro/DXF, proper collisions on all doors
2026-05-29 01:03:32 +02:00

10 KiB

InteriorScan — Data Model

Entity Relationship

users ──< team_members >── teams ──< projects ──< photos
                              │                    │
                              │                    └── walkthroughs
                              │
                              └── subscriptions

projects ──< processing_jobs

Drizzle Schema Definition

// 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(),
});

// ── Catastro Data ─────────────────────────────────────

export const catastroData = pgTable('catastro_data', {
  id: cuid2('id').primaryKey(),
  projectId: cuid2('project_id').references(() => projects.id).notNull(),
  refcat: text('refcat'),                          // 14-20 digit cadastral reference
  addressNormalized: text('address_normalized'),    // Normalized address from Catastro
  buildingFootprint: jsonb('building_footprint'),  // GeoJSON polygon from INSPIRE WFS
  buildingHeight: text('building_height'),          // e.g., "12.5" meters
  numberOfFloors: integer('number_of_floors'),      // From Catastro
  floorArea: text('floor_area'),                    // m² from Catastro
  gmlSource: text('gml_source'),                    // URL of the GML source file
  fetchedAt: timestamp('fetched_at').defaultNow().notNull(),
});

export const catastroImports = pgTable('catastro_imports', {
  id: cuid2('id').primaryKey(),
  projectId: cuid2('project_id').references(() => projects.id).notNull(),
  format: text('format').notNull(),   // 'dxf', 'svg', 'png', 'gml'
  originalKey: text('original_key'),   // S3 key for uploaded file
  parsedWalls: jsonb('parsed_walls'),  // Array of {x1, z1, x2, z2, height, thickness}
  source: text('source').notNull(),    // 'catastro', 'upload', 'manual'
  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

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