be3221e93d
- 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
232 lines
10 KiB
Markdown
232 lines
10 KiB
Markdown
# 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(),
|
|
});
|
|
|
|
// ── 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
|
|
|
|
```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 | |