schema.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import { pgTable, text, timestamp, boolean, integer, jsonb, serial, pgEnum, index, uniqueIndex } from 'drizzle-orm/pg-core'
  2. import { relations } from 'drizzle-orm'
  3. // ---- Enums ----
  4. export const orgStatusEnum = pgEnum('org_status', ['active', 'suspended', 'trial'])
  5. export const projectStatusEnum = pgEnum('project_status', ['planning', 'active', 'paused', 'completed', 'archived'])
  6. export const deviceStatusEnum = pgEnum('device_status', ['offline', 'online', 'capturing', 'uploading', 'degraded', 'updating', 'error'])
  7. export const captureStatusEnum = pgEnum('capture_status', ['pending', 'uploaded', 'processing', 'ready', 'failed'])
  8. export const videoStatusEnum = pgEnum('video_status', ['pending', 'processing', 'ready', 'failed'])
  9. export const alertSeverityEnum = pgEnum('alert_severity', ['info', 'warning', 'error', 'critical'])
  10. export const alertTypeEnum = pgEnum('alert_type', ['device_offline', 'device_error', 'storage_full', 'upload_failed', 'capture_missed', 'video_failed', 'firmware_update_available'])
  11. export const alertStateEnum = pgEnum('alert_state', ['open', 'acknowledged', 'resolved'])
  12. export const userRoleEnum = pgEnum('user_role', ['super_admin', 'org_admin', 'project_manager', 'viewer'])
  13. export const commandResultEnum = pgEnum('command_result_status', ['pending', 'delivered', 'acknowledged', 'success', 'failed', 'timeout'])
  14. // ---- Tenancy ----
  15. export const organizations = pgTable('organizations', {
  16. id: text('id').primaryKey(), // nanoid
  17. name: text('name').notNull(),
  18. status: orgStatusEnum('status').default('trial').notNull(),
  19. planTier: text('plan_tier').default('free').notNull(),
  20. createdAt: timestamp('created_at').defaultNow().notNull(),
  21. updatedAt: timestamp('updated_at').defaultNow().notNull(),
  22. })
  23. export const projects = pgTable('projects', {
  24. id: text('id').primaryKey(),
  25. orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
  26. name: text('name').notNull(),
  27. description: text('description'),
  28. timezone: text('timezone').default('Asia/Ho_Chi_Minh').notNull(),
  29. startDate: timestamp('start_date'),
  30. endDate: timestamp('end_date'),
  31. status: projectStatusEnum('status').default('planning').notNull(),
  32. captureInterval: integer('capture_interval').default(60).notNull(), // minutes
  33. resolution: text('resolution').default('1920x1080').notNull(),
  34. createdAt: timestamp('created_at').defaultNow().notNull(),
  35. updatedAt: timestamp('updated_at').defaultNow().notNull(),
  36. }, (table) => ({
  37. orgStatusIdx: index('projects_org_status_idx').on(table.orgId, table.status),
  38. }))
  39. // ---- Identity ----
  40. export const users = pgTable('users', {
  41. id: text('id').primaryKey(),
  42. email: text('email').notNull(),
  43. name: text('name').notNull(),
  44. avatarUrl: text('avatar_url'),
  45. provider: text('provider').default('email').notNull(), // 'google' | 'email'
  46. emailVerified: boolean('email_verified').default(false).notNull(),
  47. passwordHash: text('password_hash'), // null for OAuth users
  48. createdAt: timestamp('created_at').defaultNow().notNull(),
  49. updatedAt: timestamp('updated_at').defaultNow().notNull(),
  50. }, (table) => ({
  51. emailIdx: uniqueIndex('users_email_idx').on(table.email),
  52. }))
  53. export const memberships = pgTable('memberships', {
  54. id: serial('id').primaryKey(),
  55. userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  56. orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
  57. role: userRoleEnum('role').notNull(),
  58. invitedBy: text('invited_by').references(() => users.id),
  59. joinedAt: timestamp('joined_at').defaultNow().notNull(),
  60. }, (table) => ({
  61. userOrgIdx: uniqueIndex('memberships_user_org_idx').on(table.userId, table.orgId),
  62. }))
  63. export const sessions = pgTable('sessions', {
  64. id: text('id').primaryKey(),
  65. userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  66. refreshTokenHash: text('refresh_token_hash').notNull(),
  67. expiresAt: timestamp('expires_at').notNull(),
  68. createdAt: timestamp('created_at').defaultNow().notNull(),
  69. userAgent: text('user_agent'),
  70. ipAddress: text('ip_address'),
  71. })
  72. export const magicLinks = pgTable('magic_links', {
  73. id: text('id').primaryKey(),
  74. email: text('email').notNull(),
  75. tokenHash: text('token_hash').notNull(),
  76. expiresAt: timestamp('expires_at').notNull(),
  77. usedAt: timestamp('used_at'),
  78. createdAt: timestamp('created_at').defaultNow().notNull(),
  79. }, (table) => ({
  80. emailExpiresIdx: index('magic_links_email_expires_idx').on(table.email, table.expiresAt),
  81. }))
  82. // ---- Devices ----
  83. export const devices = pgTable('devices', {
  84. id: text('id').primaryKey(),
  85. projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
  86. orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
  87. serialNo: text('serial_no').notNull(),
  88. name: text('name').notNull(),
  89. apiKeyHash: text('api_key_hash').notNull(),
  90. firmwareVersion: text('firmware_version'),
  91. lastSeenAt: timestamp('last_seen_at'),
  92. status: deviceStatusEnum('status').default('offline').notNull(),
  93. config: jsonb('config').$type<{
  94. captureIntervalMinutes: number
  95. resolution: string
  96. quality: number
  97. uploadOnWifiOnly: boolean
  98. nightModeEnabled: boolean
  99. nightModeStart: string
  100. nightModeEnd: string
  101. maxStorageGb: number
  102. heartbeatIntervalSeconds: number
  103. timezone: string
  104. }>().default({
  105. captureIntervalMinutes: 60,
  106. resolution: '1920x1080',
  107. quality: 85,
  108. uploadOnWifiOnly: false,
  109. nightModeEnabled: false,
  110. nightModeStart: '20:00',
  111. nightModeEnd: '06:00',
  112. maxStorageGb: 64,
  113. heartbeatIntervalSeconds: 300,
  114. timezone: 'Asia/Ho_Chi_Minh',
  115. }).notNull(),
  116. claimCode: text('claim_code'),
  117. createdAt: timestamp('created_at').defaultNow().notNull(),
  118. updatedAt: timestamp('updated_at').defaultNow().notNull(),
  119. }, (table) => ({
  120. projectStatusIdx: index('devices_project_status_idx').on(table.projectId, table.status),
  121. serialNoIdx: uniqueIndex('devices_serial_no_idx').on(table.serialNo),
  122. }))
  123. export const deviceHeartbeats = pgTable('device_heartbeats', {
  124. id: serial('id').primaryKey(),
  125. deviceId: text('device_id').references(() => devices.id, { onDelete: 'cascade' }).notNull(),
  126. heartbeatAt: timestamp('heartbeat_at').defaultNow().notNull(),
  127. tempC: integer('temp_c'),
  128. batteryPct: integer('battery_pct'),
  129. storageFreeGb: integer('storage_free_gb'),
  130. capturesToday: integer('captures_today').default(0),
  131. lastCaptureAt: timestamp('last_capture_at'),
  132. networkStatus: text('network_status').default('online'),
  133. }, (table) => ({
  134. deviceHeartbeatIdx: index('device_heartbeats_device_idx').on(table.deviceId, table.heartbeatAt),
  135. }))
  136. // ---- Captures ----
  137. export const captures = pgTable('captures', {
  138. id: text('id').primaryKey(),
  139. projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
  140. deviceId: text('device_id').references(() => devices.id, { onDelete: 'cascade' }).notNull(),
  141. capturedAt: timestamp('captured_at').notNull(),
  142. uploadedAt: timestamp('uploaded_at'),
  143. fileKey: text('file_key'),
  144. thumbnailKey: text('thumbnail_key'),
  145. checksum: text('checksum'),
  146. resolution: text('resolution'),
  147. fileSizeBytes: integer('file_size_bytes'),
  148. exposureMs: integer('exposure_ms'),
  149. iso: integer('iso'),
  150. aperture: text('aperture'),
  151. gpsLat: text('gps_lat'),
  152. gpsLng: text('gps_lng'),
  153. status: captureStatusEnum('status').default('pending').notNull(),
  154. metadata: jsonb('metadata').default({}).notNull(),
  155. createdAt: timestamp('created_at').defaultNow().notNull(),
  156. }, (table) => ({
  157. deviceCapturedIdx: index('captures_device_captured_idx').on(table.deviceId, table.capturedAt),
  158. projectCapturedIdx: index('captures_project_captured_idx').on(table.projectId, table.capturedAt),
  159. statusUploadedIdx: index('captures_status_uploaded_idx').on(table.status, table.uploadedAt),
  160. }))
  161. // ---- Videos ----
  162. export const videos = pgTable('videos', {
  163. id: text('id').primaryKey(),
  164. projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
  165. periodStart: timestamp('period_start').notNull(),
  166. periodEnd: timestamp('period_end').notNull(),
  167. fps: integer('fps').default(24).notNull(),
  168. resolution: text('resolution').default('1920x1080').notNull(),
  169. fileKey: text('file_key'),
  170. thumbnailKey: text('thumbnail_key'),
  171. durationSec: integer('duration_sec'),
  172. status: videoStatusEnum('status').default('pending').notNull(),
  173. generatedAt: timestamp('generated_at'),
  174. fileSizeBytes: integer('file_size_bytes'),
  175. createdAt: timestamp('created_at').defaultNow().notNull(),
  176. }, (table) => ({
  177. projectGeneratedIdx: index('videos_project_generated_idx').on(table.projectId, table.generatedAt),
  178. }))
  179. export const videoJobs = pgTable('video_jobs', {
  180. id: text('id').primaryKey(),
  181. projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
  182. videoId: text('video_id').references(() => videos.id, { onDelete: 'cascade' }),
  183. jobType: text('job_type').notNull(), // 'daily', 'weekly', 'custom'
  184. params: jsonb('params').default({}).notNull(),
  185. status: text('status').default('pending').notNull(),
  186. attempts: integer('attempts').default(0),
  187. startedAt: timestamp('started_at'),
  188. completedAt: timestamp('completed_at'),
  189. errorMsg: text('error_msg'),
  190. createdAt: timestamp('created_at').defaultNow().notNull(),
  191. })
  192. // ---- Commands ----
  193. export const commands = pgTable('commands', {
  194. id: text('id').primaryKey(),
  195. deviceId: text('device_id').references(() => devices.id, { onDelete: 'cascade' }).notNull(),
  196. commandType: text('command_type').notNull(), // DeviceCommandType
  197. payload: jsonb('payload'),
  198. scheduledAt: timestamp('scheduled_at'),
  199. queuedAt: timestamp('queued_at').defaultNow().notNull(),
  200. deliveredAt: timestamp('delivered_at'),
  201. acknowledgedAt: timestamp('acknowledged_at'),
  202. resultStatus: commandResultEnum('result_status').default('pending').notNull(),
  203. resultData: jsonb('result_data'),
  204. }, (table) => ({
  205. deviceQueuedIdx: index('commands_device_queued_idx').on(table.deviceId, table.queuedAt),
  206. pendingIdx: index('commands_pending_idx').on(table.resultStatus, table.scheduledAt),
  207. }))
  208. // ---- Alerts ----
  209. export const alertRules = pgTable('alert_rules', {
  210. id: text('id').primaryKey(),
  211. orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
  212. name: text('name').notNull(),
  213. type: alertTypeEnum('type').notNull(),
  214. condition: jsonb('condition').notNull(),
  215. cooldownMinutes: integer('cooldown_minutes').default(15),
  216. enabled: boolean('enabled').default(true).notNull(),
  217. notifyEmail: boolean('notify_email').default(false).notNull(),
  218. notifySms: boolean('notify_sms').default(false).notNull(),
  219. webhookUrl: text('webhook_url'),
  220. createdAt: timestamp('created_at').defaultNow().notNull(),
  221. })
  222. export const alerts = pgTable('alerts', {
  223. id: text('id').primaryKey(),
  224. orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
  225. projectId: text('project_id').references(() => projects.id, { onDelete: 'set null' }),
  226. deviceId: text('device_id').references(() => devices.id, { onDelete: 'set null' }),
  227. type: alertTypeEnum('type').notNull(),
  228. severity: alertSeverityEnum('severity').notNull(),
  229. message: text('message').notNull(),
  230. data: jsonb('data').default({}).notNull(),
  231. state: alertStateEnum('state').default('open').notNull(),
  232. openedAt: timestamp('opened_at').defaultNow().notNull(),
  233. acknowledgedAt: timestamp('acknowledged_at'),
  234. acknowledgedBy: text('acknowledged_by').references(() => users.id),
  235. resolvedAt: timestamp('resolved_at'),
  236. }, (table) => ({
  237. orgStateIdx: index('alerts_org_state_idx').on(table.orgId, table.state),
  238. projectIdx: index('alerts_project_idx').on(table.projectId),
  239. }))
  240. // ---- Audit ----
  241. export const auditLogs = pgTable('audit_logs', {
  242. id: serial('id').primaryKey(),
  243. orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }),
  244. userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
  245. action: text('action').notNull(),
  246. resourceType: text('resource_type').notNull(),
  247. resourceId: text('resource_id'),
  248. ipAddress: text('ip_address'),
  249. userAgent: text('user_agent'),
  250. metadata: jsonb('metadata').default({}),
  251. createdAt: timestamp('created_at').defaultNow().notNull(),
  252. }, (table) => ({
  253. orgCreatedIdx: index('audit_logs_org_created_idx').on(table.orgId, table.createdAt),
  254. }))
  255. export const activityLogs = pgTable('activity_logs', {
  256. id: serial('id').primaryKey(),
  257. orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }),
  258. projectId: text('project_id').references(() => projects.id, { onDelete: 'set null' }),
  259. userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
  260. eventType: text('event_type').notNull(),
  261. metadata: jsonb('metadata').default({}),
  262. createdAt: timestamp('created_at').defaultNow().notNull(),
  263. }, (table) => ({
  264. projectCreatedIdx: index('activity_logs_project_created_idx').on(table.projectId, table.createdAt),
  265. }))
  266. // ---- Relations ----
  267. export const organizationsRelations = relations(organizations, ({ many }) => ({
  268. projects: many(projects),
  269. memberships: many(memberships),
  270. }))
  271. export const projectsRelations = relations(projects, ({ one, many }) => ({
  272. organization: one(organizations, { fields: [projects.orgId], references: [organizations.id] }),
  273. devices: many(devices),
  274. captures: many(captures),
  275. videos: many(videos),
  276. alerts: many(alerts),
  277. }))
  278. export const usersRelations = relations(users, ({ many }) => ({
  279. memberships: many(memberships),
  280. sessions: many(sessions),
  281. }))
  282. export const membershipsRelations = relations(memberships, ({ one }) => ({
  283. user: one(users, { fields: [memberships.userId], references: [users.id] }),
  284. organization: one(organizations, { fields: [memberships.orgId], references: [organizations.id] }),
  285. }))
  286. export const devicesRelations = relations(devices, ({ one, many }) => ({
  287. project: one(projects, { fields: [devices.projectId], references: [projects.id] }),
  288. organization: one(organizations, { fields: [devices.orgId], references: [organizations.id] }),
  289. captures: many(captures),
  290. heartbeats: many(deviceHeartbeats),
  291. commands: many(commands),
  292. alerts: many(alerts),
  293. }))
  294. export const capturesRelations = relations(captures, ({ one }) => ({
  295. project: one(projects, { fields: [captures.projectId], references: [projects.id] }),
  296. device: one(devices, { fields: [captures.deviceId], references: [devices.id] }),
  297. }))
  298. export const videosRelations = relations(videos, ({ one }) => ({
  299. project: one(projects, { fields: [videos.projectId], references: [projects.id] }),
  300. }))