| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- import { pgTable, text, timestamp, boolean, integer, jsonb, serial, pgEnum, index, uniqueIndex } from 'drizzle-orm/pg-core'
- import { relations } from 'drizzle-orm'
- // ---- Enums ----
- export const orgStatusEnum = pgEnum('org_status', ['active', 'suspended', 'trial'])
- export const projectStatusEnum = pgEnum('project_status', ['planning', 'active', 'paused', 'completed', 'archived'])
- export const deviceStatusEnum = pgEnum('device_status', ['offline', 'online', 'capturing', 'uploading', 'degraded', 'updating', 'error'])
- export const captureStatusEnum = pgEnum('capture_status', ['pending', 'uploaded', 'processing', 'ready', 'failed'])
- export const videoStatusEnum = pgEnum('video_status', ['pending', 'processing', 'ready', 'failed'])
- export const alertSeverityEnum = pgEnum('alert_severity', ['info', 'warning', 'error', 'critical'])
- export const alertTypeEnum = pgEnum('alert_type', ['device_offline', 'device_error', 'storage_full', 'upload_failed', 'capture_missed', 'video_failed', 'firmware_update_available'])
- export const alertStateEnum = pgEnum('alert_state', ['open', 'acknowledged', 'resolved'])
- export const userRoleEnum = pgEnum('user_role', ['super_admin', 'org_admin', 'project_manager', 'viewer'])
- export const commandResultEnum = pgEnum('command_result_status', ['pending', 'delivered', 'acknowledged', 'success', 'failed', 'timeout'])
- // ---- Tenancy ----
- export const organizations = pgTable('organizations', {
- id: text('id').primaryKey(), // nanoid
- name: text('name').notNull(),
- status: orgStatusEnum('status').default('trial').notNull(),
- planTier: text('plan_tier').default('free').notNull(),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
- })
- export const projects = pgTable('projects', {
- id: text('id').primaryKey(),
- orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
- name: text('name').notNull(),
- description: text('description'),
- timezone: text('timezone').default('Asia/Ho_Chi_Minh').notNull(),
- startDate: timestamp('start_date'),
- endDate: timestamp('end_date'),
- status: projectStatusEnum('status').default('planning').notNull(),
- captureInterval: integer('capture_interval').default(60).notNull(), // minutes
- resolution: text('resolution').default('1920x1080').notNull(),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
- }, (table) => ({
- orgStatusIdx: index('projects_org_status_idx').on(table.orgId, table.status),
- }))
- // ---- Identity ----
- export const users = pgTable('users', {
- id: text('id').primaryKey(),
- email: text('email').notNull(),
- name: text('name').notNull(),
- avatarUrl: text('avatar_url'),
- provider: text('provider').default('email').notNull(), // 'google' | 'email'
- emailVerified: boolean('email_verified').default(false).notNull(),
- passwordHash: text('password_hash'), // null for OAuth users
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
- }, (table) => ({
- emailIdx: uniqueIndex('users_email_idx').on(table.email),
- }))
- export const memberships = pgTable('memberships', {
- id: serial('id').primaryKey(),
- userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
- orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
- role: userRoleEnum('role').notNull(),
- invitedBy: text('invited_by').references(() => users.id),
- joinedAt: timestamp('joined_at').defaultNow().notNull(),
- }, (table) => ({
- userOrgIdx: uniqueIndex('memberships_user_org_idx').on(table.userId, table.orgId),
- }))
- export const sessions = pgTable('sessions', {
- id: text('id').primaryKey(),
- userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
- refreshTokenHash: text('refresh_token_hash').notNull(),
- expiresAt: timestamp('expires_at').notNull(),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- userAgent: text('user_agent'),
- ipAddress: text('ip_address'),
- })
- export const magicLinks = pgTable('magic_links', {
- id: text('id').primaryKey(),
- email: text('email').notNull(),
- tokenHash: text('token_hash').notNull(),
- expiresAt: timestamp('expires_at').notNull(),
- usedAt: timestamp('used_at'),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- }, (table) => ({
- emailExpiresIdx: index('magic_links_email_expires_idx').on(table.email, table.expiresAt),
- }))
- // ---- Devices ----
- export const devices = pgTable('devices', {
- id: text('id').primaryKey(),
- projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
- orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
- serialNo: text('serial_no').notNull(),
- name: text('name').notNull(),
- apiKeyHash: text('api_key_hash').notNull(),
- firmwareVersion: text('firmware_version'),
- lastSeenAt: timestamp('last_seen_at'),
- status: deviceStatusEnum('status').default('offline').notNull(),
- config: jsonb('config').$type<{
- captureIntervalMinutes: number
- resolution: string
- quality: number
- uploadOnWifiOnly: boolean
- nightModeEnabled: boolean
- nightModeStart: string
- nightModeEnd: string
- maxStorageGb: number
- heartbeatIntervalSeconds: number
- timezone: string
- }>().default({
- captureIntervalMinutes: 60,
- resolution: '1920x1080',
- quality: 85,
- uploadOnWifiOnly: false,
- nightModeEnabled: false,
- nightModeStart: '20:00',
- nightModeEnd: '06:00',
- maxStorageGb: 64,
- heartbeatIntervalSeconds: 300,
- timezone: 'Asia/Ho_Chi_Minh',
- }).notNull(),
- claimCode: text('claim_code'),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
- }, (table) => ({
- projectStatusIdx: index('devices_project_status_idx').on(table.projectId, table.status),
- serialNoIdx: uniqueIndex('devices_serial_no_idx').on(table.serialNo),
- }))
- export const deviceHeartbeats = pgTable('device_heartbeats', {
- id: serial('id').primaryKey(),
- deviceId: text('device_id').references(() => devices.id, { onDelete: 'cascade' }).notNull(),
- heartbeatAt: timestamp('heartbeat_at').defaultNow().notNull(),
- tempC: integer('temp_c'),
- batteryPct: integer('battery_pct'),
- storageFreeGb: integer('storage_free_gb'),
- capturesToday: integer('captures_today').default(0),
- lastCaptureAt: timestamp('last_capture_at'),
- networkStatus: text('network_status').default('online'),
- }, (table) => ({
- deviceHeartbeatIdx: index('device_heartbeats_device_idx').on(table.deviceId, table.heartbeatAt),
- }))
- // ---- Captures ----
- export const captures = pgTable('captures', {
- id: text('id').primaryKey(),
- projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
- deviceId: text('device_id').references(() => devices.id, { onDelete: 'cascade' }).notNull(),
- capturedAt: timestamp('captured_at').notNull(),
- uploadedAt: timestamp('uploaded_at'),
- fileKey: text('file_key'),
- thumbnailKey: text('thumbnail_key'),
- checksum: text('checksum'),
- resolution: text('resolution'),
- fileSizeBytes: integer('file_size_bytes'),
- exposureMs: integer('exposure_ms'),
- iso: integer('iso'),
- aperture: text('aperture'),
- gpsLat: text('gps_lat'),
- gpsLng: text('gps_lng'),
- status: captureStatusEnum('status').default('pending').notNull(),
- metadata: jsonb('metadata').default({}).notNull(),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- }, (table) => ({
- deviceCapturedIdx: index('captures_device_captured_idx').on(table.deviceId, table.capturedAt),
- projectCapturedIdx: index('captures_project_captured_idx').on(table.projectId, table.capturedAt),
- statusUploadedIdx: index('captures_status_uploaded_idx').on(table.status, table.uploadedAt),
- }))
- // ---- Videos ----
- export const videos = pgTable('videos', {
- id: text('id').primaryKey(),
- projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
- periodStart: timestamp('period_start').notNull(),
- periodEnd: timestamp('period_end').notNull(),
- fps: integer('fps').default(24).notNull(),
- resolution: text('resolution').default('1920x1080').notNull(),
- fileKey: text('file_key'),
- thumbnailKey: text('thumbnail_key'),
- durationSec: integer('duration_sec'),
- status: videoStatusEnum('status').default('pending').notNull(),
- generatedAt: timestamp('generated_at'),
- fileSizeBytes: integer('file_size_bytes'),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- }, (table) => ({
- projectGeneratedIdx: index('videos_project_generated_idx').on(table.projectId, table.generatedAt),
- }))
- export const videoJobs = pgTable('video_jobs', {
- id: text('id').primaryKey(),
- projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
- videoId: text('video_id').references(() => videos.id, { onDelete: 'cascade' }),
- jobType: text('job_type').notNull(), // 'daily', 'weekly', 'custom'
- params: jsonb('params').default({}).notNull(),
- status: text('status').default('pending').notNull(),
- attempts: integer('attempts').default(0),
- startedAt: timestamp('started_at'),
- completedAt: timestamp('completed_at'),
- errorMsg: text('error_msg'),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- })
- // ---- Commands ----
- export const commands = pgTable('commands', {
- id: text('id').primaryKey(),
- deviceId: text('device_id').references(() => devices.id, { onDelete: 'cascade' }).notNull(),
- commandType: text('command_type').notNull(), // DeviceCommandType
- payload: jsonb('payload'),
- scheduledAt: timestamp('scheduled_at'),
- queuedAt: timestamp('queued_at').defaultNow().notNull(),
- deliveredAt: timestamp('delivered_at'),
- acknowledgedAt: timestamp('acknowledged_at'),
- resultStatus: commandResultEnum('result_status').default('pending').notNull(),
- resultData: jsonb('result_data'),
- }, (table) => ({
- deviceQueuedIdx: index('commands_device_queued_idx').on(table.deviceId, table.queuedAt),
- pendingIdx: index('commands_pending_idx').on(table.resultStatus, table.scheduledAt),
- }))
- // ---- Alerts ----
- export const alertRules = pgTable('alert_rules', {
- id: text('id').primaryKey(),
- orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
- name: text('name').notNull(),
- type: alertTypeEnum('type').notNull(),
- condition: jsonb('condition').notNull(),
- cooldownMinutes: integer('cooldown_minutes').default(15),
- enabled: boolean('enabled').default(true).notNull(),
- notifyEmail: boolean('notify_email').default(false).notNull(),
- notifySms: boolean('notify_sms').default(false).notNull(),
- webhookUrl: text('webhook_url'),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- })
- export const alerts = pgTable('alerts', {
- id: text('id').primaryKey(),
- orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
- projectId: text('project_id').references(() => projects.id, { onDelete: 'set null' }),
- deviceId: text('device_id').references(() => devices.id, { onDelete: 'set null' }),
- type: alertTypeEnum('type').notNull(),
- severity: alertSeverityEnum('severity').notNull(),
- message: text('message').notNull(),
- data: jsonb('data').default({}).notNull(),
- state: alertStateEnum('state').default('open').notNull(),
- openedAt: timestamp('opened_at').defaultNow().notNull(),
- acknowledgedAt: timestamp('acknowledged_at'),
- acknowledgedBy: text('acknowledged_by').references(() => users.id),
- resolvedAt: timestamp('resolved_at'),
- }, (table) => ({
- orgStateIdx: index('alerts_org_state_idx').on(table.orgId, table.state),
- projectIdx: index('alerts_project_idx').on(table.projectId),
- }))
- // ---- Audit ----
- export const auditLogs = pgTable('audit_logs', {
- id: serial('id').primaryKey(),
- orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }),
- userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
- action: text('action').notNull(),
- resourceType: text('resource_type').notNull(),
- resourceId: text('resource_id'),
- ipAddress: text('ip_address'),
- userAgent: text('user_agent'),
- metadata: jsonb('metadata').default({}),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- }, (table) => ({
- orgCreatedIdx: index('audit_logs_org_created_idx').on(table.orgId, table.createdAt),
- }))
- export const activityLogs = pgTable('activity_logs', {
- id: serial('id').primaryKey(),
- orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }),
- projectId: text('project_id').references(() => projects.id, { onDelete: 'set null' }),
- userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
- eventType: text('event_type').notNull(),
- metadata: jsonb('metadata').default({}),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- }, (table) => ({
- projectCreatedIdx: index('activity_logs_project_created_idx').on(table.projectId, table.createdAt),
- }))
- // ---- Relations ----
- export const organizationsRelations = relations(organizations, ({ many }) => ({
- projects: many(projects),
- memberships: many(memberships),
- }))
- export const projectsRelations = relations(projects, ({ one, many }) => ({
- organization: one(organizations, { fields: [projects.orgId], references: [organizations.id] }),
- devices: many(devices),
- captures: many(captures),
- videos: many(videos),
- alerts: many(alerts),
- }))
- export const usersRelations = relations(users, ({ many }) => ({
- memberships: many(memberships),
- sessions: many(sessions),
- }))
- export const membershipsRelations = relations(memberships, ({ one }) => ({
- user: one(users, { fields: [memberships.userId], references: [users.id] }),
- organization: one(organizations, { fields: [memberships.orgId], references: [organizations.id] }),
- }))
- export const devicesRelations = relations(devices, ({ one, many }) => ({
- project: one(projects, { fields: [devices.projectId], references: [projects.id] }),
- organization: one(organizations, { fields: [devices.orgId], references: [organizations.id] }),
- captures: many(captures),
- heartbeats: many(deviceHeartbeats),
- commands: many(commands),
- alerts: many(alerts),
- }))
- export const capturesRelations = relations(captures, ({ one }) => ({
- project: one(projects, { fields: [captures.projectId], references: [projects.id] }),
- device: one(devices, { fields: [captures.deviceId], references: [devices.id] }),
- }))
- export const videosRelations = relations(videos, ({ one }) => ({
- project: one(projects, { fields: [videos.projectId], references: [projects.id] }),
- }))
|