@de-otio/trellis 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/lib/crypto/voting/hash-utils.d.ts +3 -49
  2. package/dist/lib/crypto/voting/hash-utils.d.ts.map +1 -1
  3. package/dist/lib/crypto/voting/hash-utils.js +12 -54
  4. package/dist/lib/crypto/voting/hash-utils.js.map +1 -1
  5. package/dist/lib/email-privacy.d.ts +6 -44
  6. package/dist/lib/email-privacy.d.ts.map +1 -1
  7. package/dist/lib/email-privacy.js +10 -50
  8. package/dist/lib/email-privacy.js.map +1 -1
  9. package/package.json +1 -1
  10. package/prisma/migrations/20260412075058_init_redesign_schema/migration.sql +1547 -0
  11. package/prisma/migrations/20260412080000_seed_role_metadata/migration.sql +15 -0
  12. package/prisma/migrations/migration_lock.toml +3 -0
  13. package/prisma/schema.prisma +1408 -0
  14. package/dist/lib/crypto/encryption-service.d.ts +0 -100
  15. package/dist/lib/crypto/encryption-service.d.ts.map +0 -1
  16. package/dist/lib/crypto/encryption-service.js +0 -293
  17. package/dist/lib/crypto/encryption-service.js.map +0 -1
  18. package/dist/lib/crypto/index.d.ts +0 -22
  19. package/dist/lib/crypto/index.d.ts.map +0 -1
  20. package/dist/lib/crypto/index.js +0 -28
  21. package/dist/lib/crypto/index.js.map +0 -1
  22. package/dist/lib/crypto/types.d.ts +0 -71
  23. package/dist/lib/crypto/types.d.ts.map +0 -1
  24. package/dist/lib/crypto/types.js +0 -3
  25. package/dist/lib/crypto/types.js.map +0 -1
  26. package/dist/lib/crypto/versioning.d.ts +0 -112
  27. package/dist/lib/crypto/versioning.d.ts.map +0 -1
  28. package/dist/lib/crypto/versioning.js +0 -148
  29. package/dist/lib/crypto/versioning.js.map +0 -1
  30. package/dist/lib/encryption-key-service.d.ts +0 -115
  31. package/dist/lib/encryption-key-service.d.ts.map +0 -1
  32. package/dist/lib/encryption-key-service.js +0 -272
  33. package/dist/lib/encryption-key-service.js.map +0 -1
  34. package/dist/lib/followers-handler.d.ts +0 -21
  35. package/dist/lib/followers-handler.d.ts.map +0 -1
  36. package/dist/lib/followers-handler.js +0 -35
  37. package/dist/lib/followers-handler.js.map +0 -1
  38. package/dist/lib/routes/followers.d.ts +0 -6
  39. package/dist/lib/routes/followers.d.ts.map +0 -1
  40. package/dist/lib/routes/followers.js +0 -405
  41. package/dist/lib/routes/followers.js.map +0 -1
@@ -0,0 +1,1408 @@
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ binaryTargets = ["native", "rhel-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]
4
+ }
5
+
6
+ datasource db {
7
+ provider = "postgresql"
8
+ url = env("DATABASE_URL")
9
+ directUrl = env("DIRECT_DATABASE_URL")
10
+ // Note: Prisma 7 will require moving url/directUrl to prisma.config.ts
11
+ // This schema is correct for Prisma 5.x
12
+ }
13
+
14
+ // Generic Entity model (replaces Dog model)
15
+ // NO tenantId - schema-per-tenant provides isolation
16
+ model Entity {
17
+ id String @id @default(cuid())
18
+ name String
19
+ entityType String? @map("entity_type") // 'pet', 'product', 'event', etc. (defaults to 'dog' for MVP)
20
+ metadata Json? // Flexible schema for entity-specific fields (breed, bio, birthdate, breedSize, etc.)
21
+
22
+ // Entity lifecycle
23
+ status EntityStatus @default(ACTIVE)
24
+ deceasedAt DateTime? @map("deceased_at")
25
+ memorialSettings Json? @map("memorial_settings") // { commentsOpen: boolean, ... }
26
+
27
+ // Life stage fields (auto-calculated from birthdate and breedSize in metadata)
28
+ lifeStage String? @map("life_stage") // Taxonomy ID: "life-stage:puppy"
29
+ lifeStageManualOverride Boolean @default(false) @map("life_stage_manual_override")
30
+ lifeStageCalculatedAt DateTime? @map("life_stage_calculated_at")
31
+
32
+ // ActivityPub federation fields
33
+ actorUri String? @unique @map("actor_uri") // e.g. https://skybber.app/ap/entities/{id}
34
+ inboxUrl String? @map("inbox_url")
35
+ outboxUrl String? @map("outbox_url")
36
+ followersUrl String? @map("followers_url")
37
+ publicKey String? @map("public_key") // PEM-encoded RSA public key
38
+ privateKey String? @map("private_key") // PEM-encoded RSA private key (encrypted at rest)
39
+
40
+ createdAt DateTime @default(now()) @map("created_at")
41
+ updatedAt DateTime @updatedAt @map("updated_at")
42
+
43
+ // Co-ownership (replaces single ownerId)
44
+ owners EntityOwnership[]
45
+ // Relationships and follower counts live in the graph DB (AuraDB), not Prisma
46
+
47
+ subjectPosts PostSubject[]
48
+ primaryPosts Post[] @relation("PrimaryEntityPosts")
49
+ taxonomyTags EntityTaxonomyTag[]
50
+
51
+ connectionCodes ConnectionCode[]
52
+
53
+ @@index([status])
54
+ @@index([entityType, status])
55
+ @@map("entities")
56
+ }
57
+
58
+ enum EntityStatus {
59
+ ACTIVE
60
+ MEMORIAL
61
+ TRANSFERRED
62
+ }
63
+
64
+ model PostGeoIndex {
65
+ postUri String @id @map("post_uri")
66
+ entityRef String? @map("entity_ref") // Generic entity reference
67
+ geohash String
68
+ lat Float
69
+ lng Float
70
+ createdAt DateTime @default(now()) @map("created_at")
71
+ place String?
72
+ labels Json? @default("[]")
73
+
74
+ // PREPARATORY: Data classification for Border Safety Mode
75
+ // Classify location data as sensitive, benign, or decoy for future selective wiping
76
+ sensitivityLevel String @default("benign") @map("sensitivity_level")
77
+ // Values: 'benign', 'sensitive', 'decoy'
78
+
79
+ @@index([entityRef])
80
+ @@index([geohash])
81
+ @@index([sensitivityLevel]) // Index for sensitivity level queries
82
+ @@map("post_geo_index")
83
+ }
84
+
85
+ model IngestState {
86
+ id String @id @default(cuid())
87
+ cursor String? @unique
88
+ lastProcessed DateTime @default(now()) @map("last_processed")
89
+ updatedAt DateTime @updatedAt @map("updated_at")
90
+
91
+ @@map("ingest_state")
92
+ }
93
+
94
+ // User roles
95
+ enum UserRole {
96
+ END_USER
97
+ B2B_PARTNER
98
+ PARTNER_ADMIN
99
+ INTERNAL
100
+ CONTENT_CREATOR
101
+ SUPER_ADMIN
102
+ }
103
+
104
+ // Role metadata for UI display and documentation
105
+ model RoleMetadata {
106
+ role UserRole @id // Maps to enum value
107
+ displayName String @map("display_name") // Human-readable name (e.g., "Partner Administrator")
108
+ description String? // Detailed description of role permissions
109
+ category String // 'partner', 'internal', 'system', 'end_user'
110
+ permissions Json? // Role-specific permissions (for UI display)
111
+ isActive Boolean @default(true) @map("is_active")
112
+ createdAt DateTime @default(now()) @map("created_at")
113
+ updatedAt DateTime @updatedAt @map("updated_at")
114
+
115
+ @@map("role_metadata")
116
+ }
117
+
118
+ // User model for feed posts
119
+ // Using Cognito sub (UUID) as primary key via cognitoSub field
120
+ // actorUri is the ActivityPub actor identifier
121
+ model User {
122
+ id String @id @default(cuid())
123
+ email String @unique
124
+ role UserRole @default(END_USER)
125
+ actorUri String? @unique @map("actor_uri") // ActivityPub actor URI (e.g., "https://skybber.com/users/{username}")
126
+ handle String? // ActivityPub handle (e.g., "@user@skybber.com")
127
+ createdAt DateTime @default(now()) @map("created_at")
128
+
129
+ // Cognito Auth integration
130
+ cognitoSub String? @unique @map("cognito_sub") // Cognito user pool sub (UUID), nullable for migration safety
131
+
132
+ // User Deprovisioning
133
+ suspended Boolean @default(false)
134
+ suspendedAt DateTime? @map("suspended_at")
135
+ suspendedReason String? @map("suspended_reason")
136
+ partnerId String?
137
+ partner Partner? @relation(fields: [partnerId], references: [id])
138
+
139
+ // Account Deletion Grace Period
140
+ deletionRequestedAt DateTime? @map("deletion_requested_at") // When user requested deletion
141
+ deletionScheduledAt DateTime? @map("deletion_scheduled_at") // When hard deletion will occur (7 days after request)
142
+ deletionConfirmedAt DateTime? @map("deletion_confirmed_at") // When user confirmed deletion (2FA/confirmation)
143
+
144
+ // PREPARATORY CHANGE: Username field for future pseudonymous sign-up
145
+ // Currently optional, will be required when implementing pseudonymous authentication
146
+ // This field allows users to sign up without email (for at-risk users)
147
+ username String? @unique
148
+
149
+ // PREPARATORY CHANGE: Privacy settings for future spy-protection features
150
+ // All fields default to current behavior (no breaking changes)
151
+ // These will be used when implementing stealth mode and location safety features
152
+
153
+ // Stealth mode: Hide online status, typing indicators, last seen
154
+ // FUTURE USE: When stealth mode is enabled, hide user's online presence
155
+ stealthMode Boolean @default(false) @map("stealth_mode")
156
+
157
+ // Online status visibility controls
158
+ // FUTURE USE: Control what presence information is visible to others
159
+ showOnlineStatus Boolean @default(true) @map("show_online_status")
160
+ showTypingIndicator Boolean @default(true) @map("show_typing_indicator")
161
+ showLastSeen Boolean @default(true) @map("show_last_seen")
162
+
163
+ // Location tracking and anonymization
164
+ // FUTURE USE: Allow users to disable location tracking or anonymize location data
165
+ locationTrackingEnabled Boolean @default(true) @map("location_tracking_enabled")
166
+ // 0=exact, 1=100m, 2=1km, 3=city-level
167
+ // FUTURE USE: Automatically round location coordinates based on this level
168
+ locationAnonymizationLevel Int @default(0) @map("location_anonymization_level")
169
+
170
+ // Analytics opt-out
171
+ // FUTURE USE: Respect user preference when sending analytics events
172
+ analyticsOptOut Boolean @default(false) @map("analytics_opt_out")
173
+
174
+ // Verification Status (synced from Cognito)
175
+ emailVerified Boolean @default(false) @map("email_verified")
176
+ emailVerifiedAt DateTime? @map("email_verified_at")
177
+
178
+ // Badge Display Preferences
179
+ showVerifiedBadge Boolean @default(true) @map("show_verified_badge") // User can hide badge
180
+
181
+ // Identity Verification (Anti-Impersonation)
182
+ // Helps users identify real accounts vs fake/impersonation accounts
183
+ identityVerified Boolean @default(false) @map("identity_verified")
184
+ identityVerifiedAt DateTime? @map("identity_verified_at")
185
+ identityVerificationMethod String? @map("identity_verification_method") // 'automated', 'manual', 'self_service'
186
+ identityVerificationProvider String? @map("identity_verification_provider") // 'jumio', 'onfido', 'veriff', 'manual'
187
+ showIdentityVerifiedBadge Boolean @default(true) @map("show_identity_verified_badge")
188
+
189
+ // PREPARATORY CHANGE: Region tracking for China expansion
190
+ // region: User's detected region (where they're located)
191
+ // dataRegion: Where user's data is stored (for compliance)
192
+ // FUTURE USE: When China expansion is implemented, dataRegion will be used
193
+ // to route data to the correct regional database
194
+ region String @default("EU") @map("region") // User's region (US, EU, CN)
195
+ dataRegion String? @map("data_region") // Where data is stored (for compliance)
196
+
197
+ posts Post[]
198
+ securityEvents SecurityEvent[]
199
+ createdInvitations Invitation[] @relation("CreatedInvitations")
200
+ usedInvitations Invitation[] @relation("UsedInvitations")
201
+ crossRegionConsents CrossRegionConsent[]
202
+
203
+ // Circle configuration and read state
204
+ circleConfig CircleConfig?
205
+ circleReadStates CircleReadState[]
206
+
207
+ // Entity co-ownership
208
+ ownedEntities EntityOwnership[] @relation("EntityOwners")
209
+ addedOwnerships EntityOwnership[] @relation("OwnershipAddedBy")
210
+
211
+ // Relationships and follower counts live in the graph DB (AuraDB), not Prisma
212
+
213
+ // Direct messages
214
+ sentMessages DirectMessage[] @relation("SentMessages")
215
+ receivedMessages DirectMessage[] @relation("ReceivedMessages")
216
+
217
+ // Custom audiences
218
+ createdAudiences CustomAudience[] @relation("AudienceCreator")
219
+ audienceMemberships CustomAudienceMember[] @relation("AudienceMembers")
220
+
221
+ // Link reports
222
+ linkReports LinkReport[]
223
+
224
+ // ActivityPub protocol fields
225
+ inboxUrl String? @map("inbox_url") // ActivityPub inbox endpoint
226
+ outboxUrl String? @map("outbox_url") // ActivityPub outbox endpoint
227
+ followersUrl String? @map("followers_url") // Followers collection URL
228
+ followingUrl String? @map("following_url") // Following collection URL
229
+ friendsUrl String? @map("friends_url") // Friends collection URL (optional)
230
+ publicKey String? @map("public_key") // Public key in PEM format
231
+ privateKey String? @map("private_key") // Private key in PEM format (encrypted at rest)
232
+
233
+ // PREPARATORY: Border Safety Mode support
234
+ // Default encryption key ID (currently unused, for future E2E encryption)
235
+ encryptionKeyId String? @map("encryption_key_id")
236
+
237
+ // Default profile context (currently always 'primary')
238
+ defaultContext String @default("primary") @map("default_context")
239
+
240
+ // Travel mode status (dormant feature flag)
241
+ travelModeActive Boolean @default(false) @map("travel_mode_active")
242
+ travelModeActivatedAt DateTime? @map("travel_mode_activated_at")
243
+
244
+ // Panic action configuration (JSON, dormant)
245
+ panicActionConfig String? @map("panic_action_config") // JSON: { enabled: boolean, action: 'wipe' | 'lock' }
246
+
247
+ // Privacy fields for email hashing and anonymous IDs
248
+ emailHash String? @unique @map("email_hash") // SHA-256 hash of email (for privacy)
249
+ anonymousId String? @unique @map("anonymous_id") // Anonymous identifier for privacy
250
+
251
+ // Data retention settings
252
+ messageRetentionDays Int? @map("message_retention_days") // How long to keep messages
253
+ autoDeleteAfterDays Int? @map("auto_delete_after_days") // Auto-delete data after N days
254
+
255
+ // Encryption keys relation
256
+ encryptionKeys UserEncryptionKey[]
257
+
258
+ // MFA enrollment relation
259
+ mfaEnrollment MfaEnrollment?
260
+
261
+ // Safer Social Design: Age verification (Phase 1)
262
+ dateOfBirth DateTime? @map("date_of_birth")
263
+ ageTier AgeTier @default(ADULT) @map("age_tier")
264
+
265
+ // Parental links
266
+ parentalLinks ParentalLink[] @relation("ChildParentalLinks")
267
+ guardianLinks ParentalLink[] @relation("GuardianParentalLinks")
268
+
269
+ // Safer Social Design: Quiet hours (Phase 2)
270
+ quietHoursStart Int? @map("quiet_hours_start") // minutes from midnight (e.g., 1320 = 10PM)
271
+ quietHoursEnd Int? @map("quiet_hours_end") // minutes from midnight (e.g., 420 = 7AM)
272
+ quietHoursEnabled Boolean @default(false) @map("quiet_hours_enabled")
273
+
274
+ // Safer Social Design: Profile visibility and DM access (Phase 5)
275
+ profileVisibility ProfileVisibility @default(PUBLIC) @map("profile_visibility")
276
+ dmAccess DmAccess @default(CONNECTIONS) @map("dm_access")
277
+
278
+ // Safer Social Design: Notifications (Phase 4)
279
+ notifications Notification[]
280
+ notificationPreference NotificationPreference?
281
+
282
+ // Connection codes (out-of-band invite mechanism)
283
+ createdConnectionCodes ConnectionCode[] @relation("ConnectionCodeCreator")
284
+ redeemedConnectionCodes ConnectionCodeRedemption[]
285
+
286
+ @@index([role])
287
+ @@index([region])
288
+ @@index([dataRegion])
289
+ @@index([suspended])
290
+ @@index([partnerId])
291
+ @@index([username])
292
+ @@index([emailVerified])
293
+ @@index([identityVerified])
294
+ @@index([actorUri]) // Index for ActivityPub actor lookups
295
+ @@index([encryptionKeyId]) // Index for encryption key lookups
296
+ @@index([travelModeActive]) // Index for travel mode queries
297
+ @@index([defaultContext]) // Index for context queries
298
+ @@index([emailHash]) // Index for email hash lookups
299
+ @@index([anonymousId]) // Index for anonymous ID lookups
300
+ @@index([cognitoSub]) // Index for Cognito sub lookups
301
+ @@map("users")
302
+ }
303
+
304
+ // MFA enrollment for admin roles (AUTH-1)
305
+ model MfaEnrollment {
306
+ id String @id @default(uuid())
307
+ userId String @unique @map("user_id")
308
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
309
+ encryptedSecret String @map("encrypted_secret") @db.Text
310
+ backupCodes String[] @map("backup_codes") // hashed, one-time use
311
+ enrolledAt DateTime @default(now()) @map("enrolled_at")
312
+ lastUsedAt DateTime? @map("last_used_at")
313
+
314
+ @@map("mfa_enrollments")
315
+ }
316
+
317
+ // User encryption keys (for Border Safety Mode and future E2E message encryption)
318
+ model UserEncryptionKey {
319
+ id String @id @default(cuid())
320
+ userId String @map("user_id")
321
+ contextId String @map("context_id") // 'primary' or 'decoy'
322
+
323
+ // Encrypted key material (encrypted with user's password-derived key)
324
+ encryptedKey String @map("encrypted_key") @db.Text
325
+
326
+ // Key derivation parameters (for PBKDF2 or Argon2)
327
+ kdfParams String @map("kdf_params") @db.Text // JSON
328
+
329
+ // Key metadata
330
+ algorithm String @default("AES-256-GCM")
331
+ keyPurpose String // 'data_encryption', 'backup', 'export', 'message_encryption'
332
+
333
+ // FUTURE: Support for E2E message encryption
334
+ // Distinguishes between Border Safety Mode keys and E2E message keys
335
+ keyType String @default("border_safety") @map("key_type")
336
+ // Values: 'border_safety' (user's own data), 'e2e_messages' (message encryption)
337
+
338
+ createdAt DateTime @default(now()) @map("created_at")
339
+ rotatedAt DateTime? @map("rotated_at")
340
+
341
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
342
+
343
+ @@unique([userId, contextId, keyPurpose, keyType])
344
+ @@index([userId])
345
+ @@index([userId, keyType]) // For querying keys by type
346
+ @@map("user_encryption_keys")
347
+ }
348
+
349
+ // Cross-region access consent for GDPR compliance
350
+ // Tracks user consent for accessing data from different regions
351
+ model CrossRegionConsent {
352
+ id String @id @default(cuid())
353
+ userId String @map("user_id")
354
+ dataRegion String @map("data_region") // Where data is stored (e.g., "EU")
355
+ accessRegion String @map("access_region") // Region user is accessing from (e.g., "US")
356
+ consented Boolean @default(false)
357
+ consentedAt DateTime? @map("consented_at")
358
+ withdrawnAt DateTime? @map("withdrawn_at")
359
+ ipAddress String? @map("ip_address")
360
+ userAgent String? @map("user_agent")
361
+ createdAt DateTime @default(now()) @map("created_at")
362
+ updatedAt DateTime @updatedAt @map("updated_at")
363
+
364
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
365
+
366
+ @@unique([userId, dataRegion, accessRegion])
367
+ @@index([userId])
368
+ @@index([consented])
369
+ @@index([dataRegion, accessRegion])
370
+ @@map("cross_region_consent")
371
+ }
372
+
373
+ // Posting radius — how far content radiates on the author's social graph
374
+ enum PostRadius {
375
+ WHISPER // Inner circle only (tier 0)
376
+ NORMAL // Close friends + inner circle (tiers 0-1)
377
+ LOUD // Community and closer (tiers 0-2)
378
+ SHOUT // Everyone (all tiers)
379
+ }
380
+
381
+ // Privacy levels for follow relationships
382
+ enum Privacy {
383
+ PUBLIC
384
+ FOLLOWERS
385
+ PRIVATE
386
+ }
387
+
388
+ // Post model (unified for all visibility levels)
389
+ model Post {
390
+ id String @id @default(cuid())
391
+ authorId String @map("author_id")
392
+ text String @db.Text
393
+ radius PostRadius @default(NORMAL)
394
+ geoData Json? @map("geo_data")
395
+ uri String? // ActivityPub URI (for public posts)
396
+ contentWarnings String[] @map("content_warnings")
397
+ deletedAt DateTime? @map("deleted_at")
398
+ hiddenByAuthor Boolean @default(false) @map("hidden_by_author")
399
+ createdAt DateTime @default(now()) @map("created_at")
400
+ updatedAt DateTime @updatedAt @map("updated_at")
401
+ editedAt DateTime? @map("edited_at") // Timestamp of last user edit (null if never edited)
402
+
403
+ author User @relation(fields: [authorId], references: [id])
404
+ group Group? @relation(fields: [groupId], references: [id])
405
+ groupId String? @map("group_id") // Group this post belongs to
406
+ media PostMedia[]
407
+ sentiments PostSentiment[]
408
+ comments PostComment[]
409
+ subjectEntities PostSubject[]
410
+ primaryEntityId String? @map("primary_entity_id")
411
+ primaryEntity Entity? @relation("PrimaryEntityPosts", fields: [primaryEntityId], references: [id])
412
+ taxonomyTags PostTaxonomyTag[]
413
+ linkChecks LinkCheck[]
414
+
415
+ // PREPARATORY CHANGE: Region tracking for China expansion
416
+ // dataRegion: Where post data is stored (for compliance)
417
+ // FUTURE USE: When China expansion is implemented, dataRegion will be used
418
+ // to route data to the correct regional database
419
+ dataRegion String? @map("data_region") // Where post data is stored (for compliance)
420
+
421
+ // ActivityPub fields (nullable for backward compatibility)
422
+ activityId String? @unique @map("activity_id") // Activity URI (e.g., "https://skybber.com/posts/{id}/activity")
423
+ objectId String? @unique @map("object_id") // Note object URI (e.g., "https://skybber.com/posts/{id}")
424
+ to Json? // Audience targeting (array of URIs)
425
+ cc Json? // Additional audience
426
+ bto Json? // Blind recipients (for private posts)
427
+ bcc Json? // Blind carbon copy
428
+ published DateTime? @map("published") // ISO 8601 timestamp (for ActivityPub, defaults to createdAt)
429
+
430
+ // Link security fields
431
+ hasBlockedLinks Boolean @default(false) @map("has_blocked_links")
432
+
433
+ // PREPARATORY: Data classification for Border Safety Mode
434
+ // Classify data as sensitive, benign, or decoy for future selective wiping
435
+ sensitivityLevel String @default("benign") @map("sensitivity_level")
436
+ // Values: 'benign', 'sensitive', 'decoy'
437
+ // Currently all posts are 'benign' (default)
438
+
439
+ // Profile context that owns this data
440
+ ownerContext String @default("primary") @map("owner_context")
441
+ // Currently always 'primary'
442
+
443
+ // Screening risk level (for content screening)
444
+ screeningRiskLevel String @default("low") @map("screening_risk_level")
445
+ // Values: 'low', 'medium', 'high'
446
+
447
+ // Content category (optional classification)
448
+ contentCategory String? @map("content_category")
449
+
450
+ @@index([authorId, createdAt])
451
+ @@index([authorId, radius, createdAt])
452
+ @@index([primaryEntityId])
453
+ @@index([createdAt])
454
+ @@index([uri])
455
+ @@index([deletedAt])
456
+ @@index([dataRegion, createdAt]) // Composite index for feed queries: filters by region and orders by creation time (most selective fields first)
457
+ @@index([activityId]) // For ActivityPub activity lookups
458
+ @@index([objectId]) // For ActivityPub object lookups
459
+ @@index([groupId]) // Index for group posts
460
+ @@index([authorId, ownerContext, sensitivityLevel]) // Composite index for context-aware data access
461
+ @@index([sensitivityLevel]) // Index for sensitivity level queries
462
+ @@index([ownerContext]) // Index for context queries
463
+ @@index([screeningRiskLevel]) // Index for risk level queries
464
+ @@index([editedAt]) // Index for querying edited posts
465
+ @@map("posts")
466
+ }
467
+
468
+ // Media files (content-addressed storage)
469
+ model MediaFile {
470
+ id String @id @default(cuid())
471
+ contentHash String @unique @map("content_hash") // SHA-256 hash
472
+ cid String? @unique // AT Protocol CID (optional, for compatibility)
473
+ mimeType String @map("mime_type")
474
+ size Int
475
+ originalKey String @map("original_key") // S3 key: media/{hash}.{ext}
476
+ thumbnailKey String? @map("thumbnail_key") // S3 key: media/{hash}_thumb.webp
477
+ optimizedKey String? @map("optimized_key") // S3 key: media/{hash}_opt.webp
478
+
479
+ // Media metadata (extracted from file headers)
480
+ width Int? // Image/video width in pixels
481
+ height Int? // Image/video height in pixels
482
+ duration Int? @map("duration") // Video duration in seconds
483
+
484
+ // Raw metadata payloads (JSON)
485
+ exifData Json? @map("exif_data")
486
+ iptcData Json? @map("iptc_data")
487
+ videoMetadata Json? @map("video_metadata")
488
+
489
+ // Denormalized/unified metadata fields
490
+ dateTaken DateTime? @map("date_taken")
491
+ gpsLatitude Float? @map("gps_latitude")
492
+ gpsLongitude Float? @map("gps_longitude")
493
+ keywords String[] @default([]) @map("keywords")
494
+
495
+ // Privacy flags
496
+ metadataVisible Boolean @default(true) @map("metadata_visible")
497
+ locationVisible Boolean @default(false) @map("location_visible")
498
+
499
+ // Media visibility and deletion tracking
500
+ hidden Boolean @default(false) // Hide from posts (soft delete)
501
+ deletedAt DateTime? @map("deleted_at") // Permanent deletion timestamp
502
+ hiddenAt DateTime? @map("hidden_at") // When media was hidden
503
+ hiddenBy String? @map("hidden_by") // User ID who hid the media
504
+
505
+ // Reconciliation fields (Option 2: Eventual Consistency)
506
+ uploadStatus String @default("PENDING") @map("upload_status") // PENDING, RECONCILING, COMPLETE, FAILED
507
+ uploadedBy String? @map("uploaded_by")
508
+ uploadBatchId String? @map("upload_batch_id")
509
+ reconciledAt DateTime? @map("reconciled_at")
510
+ reconcileAttempts Int @default(0) @map("reconcile_attempts")
511
+ createdViaReconciliation Boolean @default(false) @map("created_via_reconciliation")
512
+
513
+ // Optimistic upload tracking (for orphaned media cleanup)
514
+ attachedToPost Boolean @default(false) @map("attached_to_post") // Whether media is attached to a post
515
+ orphanedAt DateTime? @map("orphaned_at") // When media was marked as orphaned
516
+ lastAccessedAt DateTime @default(now()) @map("last_accessed_at") // Last time media was accessed
517
+
518
+ createdAt DateTime @default(now()) @map("created_at")
519
+ updatedAt DateTime @updatedAt @map("updated_at")
520
+
521
+ // Relations
522
+ posts PostMedia[] // Many-to-many with posts
523
+
524
+ @@index([contentHash])
525
+ @@index([cid])
526
+ @@index([hidden, deletedAt]) // For filtering visible media
527
+ @@index([createdAt]) // For chronological sorting
528
+ @@index([dateTaken])
529
+ @@index([metadataVisible])
530
+ @@index([locationVisible])
531
+ @@index([gpsLatitude, gpsLongitude])
532
+ @@index([uploadStatus])
533
+ @@index([uploadedBy])
534
+ @@index([uploadBatchId])
535
+ @@index([reconciledAt])
536
+ @@index([uploadStatus, reconciledAt]) // For cleanup queries
537
+ @@index([attachedToPost, createdAt]) // For orphaned media queries
538
+ @@index([orphanedAt]) // For cleanup job queries
539
+ @@map("media_files")
540
+ }
541
+
542
+ // Upload session for optimistic image uploads
543
+ model UploadSession {
544
+ id String @id @default(cuid())
545
+ userId String @map("user_id")
546
+ mediaIds String[] @map("media_ids") // Array of uploaded media IDs
547
+ status String @default("active") // 'active', 'completed', 'abandoned'
548
+ createdAt DateTime @default(now()) @map("created_at")
549
+ expiresAt DateTime @map("expires_at")
550
+
551
+ @@index([userId, status])
552
+ @@index([expiresAt])
553
+ @@map("upload_sessions")
554
+ }
555
+
556
+ // Post media
557
+ model PostMedia {
558
+ id String @id @default(cuid())
559
+ postId String @map("post_id")
560
+ mediaId String @map("media_id")
561
+ alt String?
562
+ order Int @default(0)
563
+
564
+ post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
565
+ media MediaFile @relation(fields: [mediaId], references: [id], onDelete: Restrict)
566
+
567
+ @@unique([postId, mediaId])
568
+ @@index([postId])
569
+ @@index([mediaId])
570
+ @@map("post_media")
571
+ }
572
+
573
+ // Post sentiment reactions
574
+ model PostSentiment {
575
+ id String @id @default(cuid())
576
+ postId String @map("post_id")
577
+ postUri String? @map("post_uri")
578
+ authorId String @map("author_id")
579
+ sentiment String
580
+ createdAt DateTime @default(now()) @map("created_at")
581
+
582
+ post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
583
+
584
+ @@unique([postId, authorId])
585
+ @@unique([postUri, authorId])
586
+ @@index([postId])
587
+ @@index([postUri])
588
+ @@index([authorId])
589
+ @@index([postId, sentiment]) // For faster sentiment count aggregation (groupBy)
590
+ @@index([postId, createdAt]) // For time-based sentiment queries
591
+ @@index([postId, sentiment, createdAt(sort: Desc)], name: "idx_post_sentiment_summary") // For window function query (top 3 users per sentiment)
592
+ @@index([postId, sentiment, createdAt(sort: Desc), id(sort: Desc)], name: "idx_post_sentiment_pagination") // For cursor-based pagination
593
+ @@map("post_sentiments")
594
+ }
595
+
596
+ // Post comments
597
+ model PostComment {
598
+ id String @id @default(cuid())
599
+ postId String @map("post_id")
600
+ postUri String? @map("post_uri")
601
+ authorId String @map("author_id")
602
+ text String @db.Text
603
+ rootUri String? @map("root_uri")
604
+ replyToUri String? @map("reply_to_uri")
605
+ hiddenByPostOwner Boolean @default(false) @map("hidden_by_post_owner")
606
+ createdAt DateTime @default(now()) @map("created_at")
607
+
608
+ // Edit tracking
609
+ editedAt DateTime? @map("edited_at") // When last edited
610
+ originalText String? @db.Text @map("original_text") // Preserve original on first edit
611
+
612
+ // Soft delete tracking
613
+ deletedAt DateTime? @map("deleted_at") // When deleted (soft delete)
614
+ deletedBy String? @map("deleted_by") // User ID who deleted (author or post owner)
615
+
616
+ // Link security fields
617
+ hasBlockedLinks Boolean @default(false) @map("has_blocked_links")
618
+
619
+ // PREPARATORY: Data classification for Border Safety Mode
620
+ // Classify data as sensitive, benign, or decoy for future selective wiping
621
+ sensitivityLevel String @default("benign") @map("sensitivity_level")
622
+ // Values: 'benign', 'sensitive', 'decoy'
623
+
624
+ // Profile context that owns this data
625
+ ownerContext String @default("primary") @map("owner_context")
626
+ // Currently always 'primary'
627
+
628
+ // Screening risk level (for content screening)
629
+ screeningRiskLevel String @default("low") @map("screening_risk_level")
630
+ // Values: 'low', 'medium', 'high'
631
+
632
+ // Content category (optional classification)
633
+ contentCategory String? @map("content_category")
634
+
635
+ post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
636
+ media PostCommentMedia[]
637
+ sentiments CommentSentiment[]
638
+ linkChecks LinkCheck[]
639
+
640
+ @@index([postId, createdAt])
641
+ @@index([postUri, createdAt])
642
+ @@index([authorId])
643
+ @@index([hiddenByPostOwner])
644
+ @@index([deletedAt]) // Filter deleted comments
645
+ @@index([authorId, ownerContext]) // Composite index for context-aware data access
646
+ @@index([sensitivityLevel]) // Index for sensitivity level queries
647
+ @@index([ownerContext]) // Index for context queries
648
+ @@index([screeningRiskLevel]) // Index for risk level queries
649
+ // Threading indexes
650
+ @@index([rootUri]) // Find all comments in a thread
651
+ @@index([replyToUri]) // Find direct replies to a comment
652
+ @@index([rootUri, createdAt]) // Thread comments ordered by time
653
+ @@index([postId, rootUri]) // Thread comments within a specific post
654
+ @@map("post_comments")
655
+ }
656
+
657
+ // Comment media (placeholder - MediaFile model to be defined separately)
658
+ model PostCommentMedia {
659
+ id String @id @default(cuid())
660
+ commentId String @map("comment_id")
661
+ mediaId String @map("media_id")
662
+ alt String?
663
+ order Int @default(0)
664
+
665
+ comment PostComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
666
+
667
+ @@unique([commentId, mediaId])
668
+ @@index([commentId])
669
+ @@index([mediaId])
670
+ @@map("post_comment_media")
671
+ }
672
+
673
+ // Comment sentiment reactions
674
+ model CommentSentiment {
675
+ id String @id @default(cuid())
676
+ commentId String @map("comment_id")
677
+ commentUri String? @map("comment_uri")
678
+ authorId String @map("author_id")
679
+ sentiment String
680
+ createdAt DateTime @default(now()) @map("created_at")
681
+
682
+ comment PostComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
683
+
684
+ @@unique([commentId, authorId])
685
+ @@unique([commentUri, authorId])
686
+ @@index([commentId])
687
+ @@index([commentUri])
688
+ @@index([authorId])
689
+ @@index([commentId, sentiment]) // For faster sentiment count aggregation (groupBy)
690
+ @@index([commentId, createdAt]) // For time-based sentiment queries
691
+ @@map("comment_sentiments")
692
+ }
693
+
694
+ // Post-Subject junction — which entities a post is about (primary content model)
695
+ model PostSubject {
696
+ id String @id @default(cuid())
697
+ postId String @map("post_id")
698
+ entityId String @map("entity_id")
699
+ isPrimary Boolean @default(false) @map("is_primary") // The "main" entity when multiple are featured
700
+ createdAt DateTime @default(now()) @map("created_at")
701
+
702
+ post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
703
+ entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
704
+
705
+ @@unique([postId, entityId])
706
+ @@index([postId])
707
+ @@index([entityId, createdAt]) // Core query: "posts about this entity, newest first"
708
+ @@index([entityId])
709
+ @@map("post_subjects")
710
+ }
711
+
712
+ // Security Events for SSO monitoring and compliance
713
+ model SecurityEvent {
714
+ id String @id @default(cuid())
715
+ type String // sso_login, sso_failed, sso_config_error, rate_limit_exceeded, suspicious_activity, unauthorized_access
716
+ severity String // low, medium, high, critical
717
+ userId String?
718
+ partnerId String?
719
+ ipAddress String? @map("ip_address")
720
+ userAgent String? @map("user_agent")
721
+ details String // JSON string
722
+ timestamp DateTime @default(now())
723
+
724
+ // PREPARATORY CHANGE: Retention tracking for future log retention policy
725
+ // This field stores when the log entry should be automatically deleted
726
+ // FUTURE USE: A scheduled cleanup job will delete events where retentionUntil < NOW()
727
+ // Retention periods: critical=365 days, high=90 days, medium=30 days, low=7 days
728
+ retentionUntil DateTime? @map("retention_until")
729
+
730
+ user User? @relation(fields: [userId], references: [id])
731
+ partner Partner? @relation(fields: [partnerId], references: [id])
732
+
733
+ @@index([type])
734
+ @@index([severity])
735
+ @@index([timestamp])
736
+ @@index([userId])
737
+ @@index([partnerId])
738
+ @@index([ipAddress])
739
+ @@index([retentionUntil]) // Index for efficient cleanup job queries
740
+ @@map("security_events")
741
+ }
742
+
743
+ // Partner model for B2B SSO
744
+ model Partner {
745
+ id String @id @default(cuid())
746
+ name String
747
+ createdAt DateTime @default(now()) @map("created_at")
748
+
749
+ users User[]
750
+ securityEvents SecurityEvent[]
751
+
752
+ @@map("partners")
753
+ }
754
+
755
+ // Feature Toggle model for global feature flags
756
+ model FeatureToggle {
757
+ id String @id @default(cuid())
758
+ key String @unique // e.g., "global_public_posting_enabled"
759
+ enabled Boolean @default(false)
760
+ description String? // Human-readable description
761
+ changedBy String? @map("changed_by") // Email of user who last changed it
762
+ lastChanged DateTime @default(now()) @updatedAt @map("last_changed")
763
+ createdAt DateTime @default(now()) @map("created_at")
764
+
765
+ @@index([key])
766
+ @@index([enabled])
767
+ @@map("feature_toggles")
768
+ }
769
+
770
+ // Invitation model for user-to-user invitations
771
+ model Invitation {
772
+ id String @id @default(cuid())
773
+ code String @unique // Unique invitation code (e.g., "ABC123XYZ")
774
+ createdBy String @map("created_by") // User ID who created the invitation
775
+ email String? // Optional: restrict invitation to specific email
776
+ used Boolean @default(false) // Whether invitation has been used
777
+ usedBy String? @map("used_by") // User ID who used the invitation
778
+ usedAt DateTime? @map("used_at") // When invitation was used
779
+ scannedAt DateTime? @map("scanned_at") // When invitation code was first scanned/claimed
780
+ scannedBy String? @map("scanned_by") // Temporary identifier for who scanned it (session ID or similar)
781
+ expiresAt DateTime? @map("expires_at") // Optional expiration date
782
+ createdAt DateTime @default(now()) @map("created_at")
783
+
784
+ creator User? @relation("CreatedInvitations", fields: [createdBy], references: [id])
785
+ user User? @relation("UsedInvitations", fields: [usedBy], references: [id])
786
+
787
+ @@index([code])
788
+ @@index([createdBy])
789
+ @@index([createdBy, createdAt]) // Composite index for efficient listing queries (filter by createdBy, order by createdAt)
790
+ @@index([usedBy])
791
+ @@index([used])
792
+ @@index([email])
793
+ @@index([expiresAt])
794
+ @@map("invitations")
795
+ }
796
+
797
+ // ============================================================================
798
+ // Taxonomy System (Phase 1: Core Taxonomy Foundation)
799
+ // ============================================================================
800
+
801
+ // Taxonomy dimension (top-level category)
802
+ model TaxonomyDimension {
803
+ id String @id @default(cuid())
804
+ tenantId String @map("tenant_id") // REQUIRED - Tenant-specific taxonomy
805
+ code String // e.g., "behavior", "context"
806
+ displayName String @map("display_name")
807
+ description String? @db.Text
808
+ order Int @default(0) // For UI ordering
809
+ isActive Boolean @default(true) @map("is_active")
810
+ createdAt DateTime @default(now()) @map("created_at")
811
+ updatedAt DateTime @updatedAt @map("updated_at")
812
+
813
+ categories TaxonomyCategory[]
814
+
815
+ @@unique([tenantId, code])
816
+ @@index([tenantId])
817
+ @@index([tenantId, code])
818
+ @@index([tenantId, isActive])
819
+ @@map("taxonomy_dimensions")
820
+ }
821
+
822
+ // Taxonomy category (second-level, e.g., "training" under "behavior")
823
+ model TaxonomyCategory {
824
+ id String @id @default(cuid())
825
+ tenantId String @map("tenant_id") // REQUIRED - Tenant-specific taxonomy
826
+ dimensionId String @map("dimension_id")
827
+ code String // e.g., "training", "issues"
828
+ displayName String @map("display_name")
829
+ description String? @db.Text
830
+ order Int @default(0)
831
+ isActive Boolean @default(true) @map("is_active")
832
+ createdAt DateTime @default(now()) @map("created_at")
833
+ updatedAt DateTime @updatedAt @map("updated_at")
834
+
835
+ dimension TaxonomyDimension @relation(fields: [dimensionId], references: [id], onDelete: Cascade)
836
+ taxons TaxonomyTaxon[]
837
+
838
+ @@unique([tenantId, dimensionId, code])
839
+ @@index([tenantId])
840
+ @@index([tenantId, dimensionId])
841
+ @@index([tenantId, isActive])
842
+ @@map("taxonomy_categories")
843
+ }
844
+
845
+ // Taxonomy taxon (specific term, e.g., "recall" under "behavior:training")
846
+ model TaxonomyTaxon {
847
+ id String @id @default(cuid())
848
+ tenantId String @map("tenant_id") // REQUIRED - Tenant-specific taxonomy
849
+ categoryId String @map("category_id")
850
+ taxonId String // Full ID: "behavior:training:recall"
851
+ displayName String @map("display_name")
852
+ description String? @db.Text
853
+ order Int @default(0)
854
+ isActive Boolean @default(true) @map("is_active")
855
+
856
+ // Synonyms and user terms for search matching
857
+ synonyms Json? // Array of alternative terms: ["obedience", "foundation-training"]
858
+ userTerms Json? // Array of user search terms: ["sit-stay-come", "basic-commands"]
859
+
860
+ // Hierarchical support
861
+ parentTaxonId String? @map("parent_taxon_id")
862
+ parentTaxon TaxonomyTaxon? @relation("TaxonHierarchy", fields: [parentTaxonId], references: [id])
863
+ childTaxons TaxonomyTaxon[] @relation("TaxonHierarchy")
864
+
865
+ // Translations (stored as JSON for now, can be normalized later)
866
+ translations Json? // { "en": {...}, "de": {...}, "fr": {...} }
867
+
868
+ createdAt DateTime @default(now()) @map("created_at")
869
+ updatedAt DateTime @updatedAt @map("updated_at")
870
+
871
+ category TaxonomyCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
872
+ tags PostTaxonomyTag[]
873
+ entityTags EntityTaxonomyTag[]
874
+ productTags ProductTaxonomyTag[]
875
+
876
+ @@unique([tenantId, taxonId])
877
+ @@index([tenantId])
878
+ @@index([tenantId, categoryId])
879
+ @@index([tenantId, taxonId])
880
+ @@index([tenantId, isActive])
881
+ @@index([parentTaxonId])
882
+ @@map("taxonomy_taxons")
883
+ }
884
+
885
+ // Junction table for post-taxonomy tag relationships
886
+ model PostTaxonomyTag {
887
+ id String @id @default(cuid())
888
+ postId String @map("post_id")
889
+ taxonId String @map("taxon_id")
890
+ addedBy String @map("added_by") // User ID
891
+ createdAt DateTime @default(now()) @map("created_at")
892
+
893
+ post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
894
+ taxon TaxonomyTaxon @relation(fields: [taxonId], references: [id], onDelete: Cascade)
895
+
896
+ @@unique([postId, taxonId])
897
+ @@index([postId])
898
+ @@index([taxonId])
899
+ @@index([addedBy])
900
+ @@map("post_taxonomy_tags")
901
+ }
902
+
903
+ // Junction table for entity-taxonomy tag relationships
904
+ model EntityTaxonomyTag {
905
+ id String @id @default(cuid())
906
+ entityId String @map("entity_id")
907
+ taxonId String @map("taxon_id")
908
+ createdAt DateTime @default(now()) @map("created_at")
909
+
910
+ entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
911
+ taxon TaxonomyTaxon @relation(fields: [taxonId], references: [id], onDelete: Cascade)
912
+
913
+ @@unique([entityId, taxonId])
914
+ @@index([entityId])
915
+ @@index([taxonId])
916
+ @@map("entity_taxonomy_tags")
917
+ }
918
+
919
+ // Junction table for product-taxonomy tag relationships
920
+ model ProductTaxonomyTag {
921
+ id String @id @default(cuid())
922
+ productId String @map("product_id")
923
+ taxonId String @map("taxon_id")
924
+ createdAt DateTime @default(now()) @map("created_at")
925
+
926
+ taxon TaxonomyTaxon @relation(fields: [taxonId], references: [id], onDelete: Cascade)
927
+
928
+ @@unique([productId, taxonId])
929
+ @@index([productId])
930
+ @@index([taxonId])
931
+ @@map("product_taxonomy_tags")
932
+ }
933
+
934
+ // ============================================================================
935
+ // Relationships (scored edges live in the graph DB — see graph-service.ts)
936
+ // Follow and Friendship models removed — replaced by :RELATES_TO edges in graph DB
937
+ // ============================================================================
938
+
939
+ // ============================================================================
940
+ // Co-Ownership
941
+ // ============================================================================
942
+
943
+ // Entity co-ownership — replaces single Entity.ownerId FK
944
+ model EntityOwnership {
945
+ id String @id @default(cuid())
946
+ entityId String @map("entity_id")
947
+ userId String @map("user_id")
948
+ role OwnershipRole @default(CO_OWNER)
949
+
950
+ addedByUserId String @map("added_by_user_id")
951
+ addedAt DateTime @default(now()) @map("added_at")
952
+ status OwnershipStatus @default(ACTIVE)
953
+ removedAt DateTime? @map("removed_at")
954
+
955
+ entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
956
+ user User @relation("EntityOwners", fields: [userId], references: [id], onDelete: Cascade)
957
+ addedBy User @relation("OwnershipAddedBy", fields: [addedByUserId], references: [id])
958
+
959
+ @@unique([entityId, userId])
960
+ @@index([entityId])
961
+ @@index([userId])
962
+ @@index([entityId, role])
963
+ @@map("entity_ownerships")
964
+ }
965
+
966
+ enum OwnershipRole {
967
+ PRIMARY_OWNER // Full control, can transfer primary, can delete entity
968
+ CO_OWNER // Post and manage, cannot delete entity
969
+ CARETAKER // Can post about entity, cannot modify profile
970
+ }
971
+
972
+ enum OwnershipStatus {
973
+ ACTIVE
974
+ REMOVED
975
+ LEFT
976
+ }
977
+
978
+ // ============================================================================
979
+ // Circle Configuration & Read State
980
+ // ============================================================================
981
+
982
+ // User's circle tier thresholds and view preferences
983
+ model CircleConfig {
984
+ id String @id @default(cuid())
985
+ userId String @unique @map("user_id")
986
+
987
+ // Tier thresholds (score >= threshold = in this tier)
988
+ innerThreshold Float @default(0.8) @map("inner_threshold") // tier 0
989
+ closeFriendThreshold Float @default(0.5) @map("close_friend_threshold") // tier 1
990
+ communityThreshold Float @default(0.2) @map("community_threshold") // tier 2
991
+ // Below communityThreshold = ambient (tier 3)
992
+
993
+ // View preferences
994
+ dailyDeckSize Int? @map("daily_deck_size") // null = no daily limit
995
+ glanceLimit Int @default(20) @map("glance_limit") // Max items in glance mode
996
+ depthWindowDays Int @default(7) @map("depth_window_days") // How far back depth mode goes
997
+
998
+ createdAt DateTime @default(now()) @map("created_at")
999
+ updatedAt DateTime @updatedAt @map("updated_at")
1000
+
1001
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
1002
+
1003
+ @@map("circle_configs")
1004
+ }
1005
+
1006
+ // Per-user, per-tier read tracking for "you're caught up" signal
1007
+ model CircleReadState {
1008
+ id String @id @default(cuid())
1009
+ userId String @map("user_id")
1010
+ tier Int // 0 = inner, 1 = close friends, 2 = community, 3 = ambient
1011
+
1012
+ lastReadAt DateTime @map("last_read_at")
1013
+ lastReadPostId String? @map("last_read_post_id") // Cursor for pagination
1014
+ caughtUp Boolean @default(true) @map("caught_up") // Server-computed
1015
+
1016
+ updatedAt DateTime @updatedAt @map("updated_at")
1017
+
1018
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
1019
+
1020
+ @@unique([userId, tier])
1021
+ @@index([userId])
1022
+ @@map("circle_read_states")
1023
+ }
1024
+
1025
+ // ============================================================================
1026
+ // ActivityPub Private Groups
1027
+ // ============================================================================
1028
+
1029
+ // Group model for ActivityPub private groups
1030
+ model Group {
1031
+ id String @id @default(cuid())
1032
+ name String
1033
+ description String? @db.Text
1034
+ actorUri String @unique @map("actor_uri") // Group actor URI
1035
+ inboxUrl String @map("inbox_url")
1036
+ outboxUrl String @map("outbox_url")
1037
+ followersUrl String @map("followers_url") // Members collection
1038
+ publicKey String @map("public_key") // Public key in PEM format
1039
+ privateKey String @map("private_key") // Private key in PEM format (encrypted at rest)
1040
+ privacy GroupPrivacy
1041
+ createdAt DateTime @default(now()) @map("created_at")
1042
+ updatedAt DateTime @updatedAt @map("updated_at")
1043
+
1044
+ members GroupMember[]
1045
+ posts Post[]
1046
+
1047
+ @@index([actorUri])
1048
+ @@index([privacy])
1049
+ @@map("groups")
1050
+ }
1051
+
1052
+ // Group membership model
1053
+ model GroupMember {
1054
+ id String @id @default(cuid())
1055
+ groupId String @map("group_id")
1056
+ actorUri String @map("actor_uri") // User's actor URI
1057
+ role GroupRole
1058
+ joinedAt DateTime @default(now()) @map("joined_at")
1059
+
1060
+ group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
1061
+
1062
+ @@unique([groupId, actorUri])
1063
+ @@index([groupId])
1064
+ @@index([actorUri])
1065
+ @@index([groupId, role])
1066
+ @@map("group_members")
1067
+ }
1068
+
1069
+ enum GroupPrivacy {
1070
+ PUBLIC // Anyone can join
1071
+ PRIVATE // Invite-only
1072
+ FRIENDS_ONLY // Only friends can join
1073
+ }
1074
+
1075
+ enum GroupRole {
1076
+ MEMBER
1077
+ MODERATOR
1078
+ ADMIN
1079
+ }
1080
+
1081
+ // ============================================================================
1082
+ // ActivityPub System
1083
+ // ============================================================================
1084
+
1085
+ // Activity model for ActivityPub inbox/outbox
1086
+ model Activity {
1087
+ id String @id @default(cuid())
1088
+ actorUri String @map("actor_uri") // Actor who created this activity
1089
+ type String // Create, Like, Follow, etc.
1090
+ objectId String? @map("object_id") // ActivityStreams object URI
1091
+ targetId String? @map("target_id") // Target actor/object
1092
+ to Json? // Audience (array of URIs)
1093
+ cc Json? // Additional audience
1094
+ bto Json? // Blind recipients
1095
+ bcc Json? // Blind carbon copy
1096
+ published DateTime // ISO 8601 timestamp
1097
+ receivedAt DateTime @default(now()) @map("received_at")
1098
+
1099
+ // Inbox/outbox tracking
1100
+ inboxActorUri String? @map("inbox_actor_uri") // For inbox activities
1101
+ outboxActorUri String? @map("outbox_actor_uri") // For outbox activities
1102
+
1103
+ @@index([actorUri])
1104
+ @@index([inboxActorUri, receivedAt])
1105
+ @@index([outboxActorUri, published])
1106
+ @@index([published])
1107
+ @@map("activities")
1108
+ }
1109
+
1110
+ // Direct Message model for ActivityPub DMs
1111
+ model DirectMessage {
1112
+ id String @id @default(cuid())
1113
+ senderId String @map("sender_id") // Sender's user ID (not actor URI, for foreign key)
1114
+ recipientId String @map("recipient_id") // Recipient's user ID (not actor URI, for foreign key)
1115
+ text String @db.Text
1116
+
1117
+ // PREPARATORY: E2E message encryption support (for future implementation)
1118
+ // Currently messages are plaintext. These fields prepare for E2E encryption.
1119
+ encryptedText String? @map("encrypted_text") @db.Text // Encrypted message content (future)
1120
+ encryptionKeyId String? @map("encryption_key_id") // Reference to UserEncryptionKey (keyType: 'e2e_messages')
1121
+ encryptionAlgorithm String? @default("AES-256-GCM") @map("encryption_algorithm") // Encryption algorithm
1122
+ encryptionIV String? @map("encryption_iv") // Initialization vector for encryption
1123
+
1124
+ objectId String? @unique @map("object_id") // ActivityStreams object URI
1125
+ activityId String? @unique @map("activity_id") // Create activity URI
1126
+ read Boolean @default(false)
1127
+ readAt DateTime? @map("read_at")
1128
+ createdAt DateTime @default(now()) @map("created_at")
1129
+
1130
+ sender User @relation("SentMessages", fields: [senderId], references: [id], onDelete: Cascade)
1131
+ recipient User @relation("ReceivedMessages", fields: [recipientId], references: [id], onDelete: Cascade)
1132
+
1133
+ @@index([senderId])
1134
+ @@index([recipientId])
1135
+ @@index([recipientId, read])
1136
+ @@index([encryptionKeyId]) // Index for encryption key lookups
1137
+ @@map("direct_messages")
1138
+ }
1139
+
1140
+ // Custom Audience model for ActivityPub custom audience targeting
1141
+ model CustomAudience {
1142
+ id String @id @default(cuid())
1143
+ name String
1144
+ creatorId String @map("creator_id") // Creator's user ID
1145
+ collectionId String @unique @map("collection_id") // Collection URI (e.g., "https://skybber.com/audiences/{id}")
1146
+ createdAt DateTime @default(now()) @map("created_at")
1147
+ updatedAt DateTime @updatedAt @map("updated_at")
1148
+
1149
+ creator User @relation("AudienceCreator", fields: [creatorId], references: [id], onDelete: Cascade)
1150
+ members CustomAudienceMember[]
1151
+
1152
+ @@index([creatorId])
1153
+ @@map("custom_audiences")
1154
+ }
1155
+
1156
+ // Custom Audience Member model (many-to-many relationship)
1157
+ model CustomAudienceMember {
1158
+ id String @id @default(cuid())
1159
+ audienceId String @map("audience_id")
1160
+ memberId String @map("member_id") // Member's user ID
1161
+ addedAt DateTime @default(now()) @map("added_at")
1162
+
1163
+ audience CustomAudience @relation(fields: [audienceId], references: [id], onDelete: Cascade)
1164
+ member User @relation("AudienceMembers", fields: [memberId], references: [id], onDelete: Cascade)
1165
+
1166
+ @@unique([audienceId, memberId]) // Prevent duplicate members
1167
+ @@index([audienceId])
1168
+ @@index([memberId])
1169
+ @@map("custom_audience_members")
1170
+ }
1171
+
1172
+ // ============================================================================
1173
+ // Link Security System
1174
+ // ============================================================================
1175
+
1176
+ // Domain reputation tracking
1177
+ model DomainReputation {
1178
+ id String @id @default(cuid())
1179
+ domain String @unique
1180
+ reputation Int @default(0) // -100 to +100
1181
+ status String @default("unknown") // unknown, safe, warning, blocked
1182
+ lastChecked DateTime @default(now()) @map("last_checked")
1183
+ createdAt DateTime @default(now()) @map("created_at")
1184
+ updatedAt DateTime @updatedAt @map("updated_at")
1185
+
1186
+ // Relations
1187
+ linkChecks LinkCheck[]
1188
+
1189
+ @@index([domain])
1190
+ @@index([status])
1191
+ @@index([reputation])
1192
+ @@map("domain_reputations")
1193
+ }
1194
+
1195
+ // Link check results (for posts/comments)
1196
+ model LinkCheck {
1197
+ id String @id @default(cuid())
1198
+ postId String? @map("post_id") // Nullable for comments
1199
+ commentId String? @map("comment_id") // Nullable for posts
1200
+ originalUrl String @map("original_url")
1201
+ normalizedUrl String @map("normalized_url")
1202
+ finalUrl String? @map("final_url") // After redirect resolution
1203
+ domain String
1204
+ status String // pending, safe, warning, blocked
1205
+ checkType String @map("check_type") // immediate, async
1206
+ threatIntel Json? @map("threat_intel") // Results from external APIs
1207
+ createdAt DateTime @default(now()) @map("created_at")
1208
+ checkedAt DateTime? @map("checked_at")
1209
+
1210
+ // Relations
1211
+ post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
1212
+ comment PostComment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
1213
+ domainReputation DomainReputation @relation(fields: [domain], references: [domain])
1214
+
1215
+ @@index([postId])
1216
+ @@index([commentId])
1217
+ @@index([domain])
1218
+ @@index([status])
1219
+ @@map("link_checks")
1220
+ }
1221
+
1222
+ // Link reports from users
1223
+ model LinkReport {
1224
+ id String @id @default(cuid())
1225
+ userId String @map("user_id")
1226
+ linkUrl String @map("link_url")
1227
+ domain String
1228
+ reason String?
1229
+ status String @default("pending") // pending, reviewed, resolved
1230
+ createdAt DateTime @default(now()) @map("created_at")
1231
+
1232
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
1233
+
1234
+ @@index([domain])
1235
+ @@index([status])
1236
+ @@index([userId])
1237
+ @@map("link_reports")
1238
+ }
1239
+
1240
+ // ============================================================================
1241
+ // Email Suppression (AWS SES bounce/complaint handling)
1242
+ // ============================================================================
1243
+
1244
+ model EmailSuppression {
1245
+ id String @id @default(cuid())
1246
+ email String @unique
1247
+ reason String // "bounce" | "complaint"
1248
+ bounceType String? // "permanent" | "transient" for bounces
1249
+ suppressedAt DateTime @default(now())
1250
+ createdAt DateTime @default(now())
1251
+ updatedAt DateTime @updatedAt
1252
+
1253
+ @@index([email])
1254
+ }
1255
+
1256
+ // ============================================================================
1257
+ // Deletion Audit Log (GDPR compliance proof — NOT cascade-deleted)
1258
+ // ============================================================================
1259
+
1260
+ model DeletionAuditLog {
1261
+ id String @id @default(cuid())
1262
+ userId String @map("user_id")
1263
+ email String
1264
+ requestedAt DateTime @map("requested_at")
1265
+ confirmedAt DateTime? @map("confirmed_at")
1266
+ completedAt DateTime @map("completed_at") @default(now())
1267
+ itemsDeleted Json @map("items_deleted")
1268
+ createdAt DateTime @default(now()) @map("created_at")
1269
+
1270
+ @@index([userId])
1271
+ @@map("deletion_audit_logs")
1272
+ }
1273
+
1274
+ // ============================================================================
1275
+ // Safer Social Design (Plan 003)
1276
+ // ============================================================================
1277
+
1278
+ // Age tiers for age-gated behavior
1279
+ enum AgeTier {
1280
+ CHILD // under 13
1281
+ TEEN // 13-17
1282
+ ADULT // 18+
1283
+ }
1284
+
1285
+ // Profile visibility levels
1286
+ enum ProfileVisibility {
1287
+ PUBLIC // discoverable in search, visible to all
1288
+ CONNECTIONS // visible only to mutual follows
1289
+ PRIVATE // visible only to self and guardian
1290
+ }
1291
+
1292
+ // DM access control
1293
+ enum DmAccess {
1294
+ ANYONE // anyone can DM
1295
+ CONNECTIONS // only mutual follows
1296
+ NOBODY // DMs disabled
1297
+ }
1298
+
1299
+ // Parental link status
1300
+ enum ParentalLinkStatus {
1301
+ PENDING
1302
+ ACTIVE
1303
+ REVOKED
1304
+ }
1305
+
1306
+ // Parental link between guardian and child accounts
1307
+ model ParentalLink {
1308
+ id String @id @default(cuid())
1309
+ childId String @map("child_id")
1310
+ guardianId String @map("guardian_id")
1311
+ status ParentalLinkStatus @default(PENDING)
1312
+ createdAt DateTime @default(now()) @map("created_at")
1313
+ confirmedAt DateTime? @map("confirmed_at")
1314
+
1315
+ child User @relation("ChildParentalLinks", fields: [childId], references: [id])
1316
+ guardian User @relation("GuardianParentalLinks", fields: [guardianId], references: [id])
1317
+
1318
+ @@unique([childId, guardianId])
1319
+ @@index([guardianId])
1320
+ @@map("parental_links")
1321
+ }
1322
+
1323
+ // Notification types (safe by default — no re-engagement or social proof)
1324
+ enum NotificationType {
1325
+ DIRECT_MESSAGE
1326
+ SAFETY_ALERT
1327
+ PARENTAL_LINK
1328
+ FOLLOW
1329
+ SENTIMENT_DIGEST
1330
+ SYSTEM
1331
+ RELATIONSHIP_CREATED
1332
+ RELATIONSHIP_RECIPROCATED
1333
+ TIER_CHANGED
1334
+ ENTITY_RELATIONSHIP_PROPOSED
1335
+ ENTITY_RELATIONSHIP_CONFIRMED
1336
+ CONNECTION_CODE_REDEEMED
1337
+ }
1338
+
1339
+ // Notification model (poll-based, never pushed)
1340
+ model Notification {
1341
+ id String @id @default(cuid())
1342
+ userId String @map("user_id")
1343
+ type NotificationType
1344
+ title String
1345
+ body String
1346
+ data Json?
1347
+ read Boolean @default(false)
1348
+ createdAt DateTime @default(now()) @map("created_at")
1349
+ deliveredAt DateTime? @map("delivered_at")
1350
+ batchId String? @map("batch_id")
1351
+
1352
+ user User @relation(fields: [userId], references: [id])
1353
+
1354
+ @@index([userId, read, createdAt])
1355
+ @@index([userId, batchId])
1356
+ @@map("notifications")
1357
+ }
1358
+
1359
+ // Notification preferences (safety and parental always enabled, not configurable)
1360
+ model NotificationPreference {
1361
+ id String @id @default(cuid())
1362
+ userId String @unique @map("user_id")
1363
+ dmEnabled Boolean @default(true) @map("dm_enabled")
1364
+ followEnabled Boolean @default(true) @map("follow_enabled")
1365
+ digestEnabled Boolean @default(true) @map("digest_enabled")
1366
+ systemEnabled Boolean @default(true) @map("system_enabled")
1367
+ relationshipEnabled Boolean @default(true) @map("relationship_enabled")
1368
+
1369
+ user User @relation(fields: [userId], references: [id])
1370
+
1371
+ @@map("notification_preferences")
1372
+ }
1373
+
1374
+ // Out-of-band invite codes — users share codes privately; redemption establishes
1375
+ // a relationship in the graph DB. Entity-scoped codes can be created by entity owners.
1376
+ model ConnectionCode {
1377
+ id String @id @default(cuid())
1378
+ code String @unique
1379
+ creatorId String @map("creator_id")
1380
+ entityId String? @map("entity_id")
1381
+ expiresAt DateTime @map("expires_at")
1382
+ maxUses Int @map("max_uses")
1383
+ useCount Int @default(0) @map("use_count")
1384
+ createdAt DateTime @default(now()) @map("created_at")
1385
+
1386
+ creator User @relation("ConnectionCodeCreator", fields: [creatorId], references: [id])
1387
+ entity Entity? @relation(fields: [entityId], references: [id])
1388
+ redemptions ConnectionCodeRedemption[]
1389
+
1390
+ @@index([creatorId, expiresAt])
1391
+ @@index([entityId])
1392
+ @@map("connection_codes")
1393
+ }
1394
+
1395
+ // One row per (code, redeemer). Composite unique prevents double-redemption under races.
1396
+ model ConnectionCodeRedemption {
1397
+ id String @id @default(cuid())
1398
+ codeId String @map("code_id")
1399
+ userId String @map("user_id")
1400
+ createdAt DateTime @default(now()) @map("created_at")
1401
+
1402
+ code ConnectionCode @relation(fields: [codeId], references: [id])
1403
+ user User @relation(fields: [userId], references: [id])
1404
+
1405
+ @@unique([codeId, userId])
1406
+ @@index([userId])
1407
+ @@map("connection_code_redemptions")
1408
+ }