@de-otio/trellis 0.4.0 → 0.6.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.
- package/dist/lib/crypto/voting/hash-utils.d.ts +3 -49
- package/dist/lib/crypto/voting/hash-utils.d.ts.map +1 -1
- package/dist/lib/crypto/voting/hash-utils.js +12 -54
- package/dist/lib/crypto/voting/hash-utils.js.map +1 -1
- package/dist/lib/email-privacy.d.ts +6 -44
- package/dist/lib/email-privacy.d.ts.map +1 -1
- package/dist/lib/email-privacy.js +10 -50
- package/dist/lib/email-privacy.js.map +1 -1
- package/package.json +6 -6
- package/prisma/migrations/20260412075058_init_redesign_schema/migration.sql +1547 -0
- package/prisma/migrations/20260412080000_seed_role_metadata/migration.sql +15 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +1408 -0
- package/dist/lib/crypto/encryption-service.d.ts +0 -100
- package/dist/lib/crypto/encryption-service.d.ts.map +0 -1
- package/dist/lib/crypto/encryption-service.js +0 -293
- package/dist/lib/crypto/encryption-service.js.map +0 -1
- package/dist/lib/crypto/index.d.ts +0 -22
- package/dist/lib/crypto/index.d.ts.map +0 -1
- package/dist/lib/crypto/index.js +0 -28
- package/dist/lib/crypto/index.js.map +0 -1
- package/dist/lib/crypto/types.d.ts +0 -71
- package/dist/lib/crypto/types.d.ts.map +0 -1
- package/dist/lib/crypto/types.js +0 -3
- package/dist/lib/crypto/types.js.map +0 -1
- package/dist/lib/crypto/versioning.d.ts +0 -112
- package/dist/lib/crypto/versioning.d.ts.map +0 -1
- package/dist/lib/crypto/versioning.js +0 -148
- package/dist/lib/crypto/versioning.js.map +0 -1
- package/dist/lib/encryption-key-service.d.ts +0 -115
- package/dist/lib/encryption-key-service.d.ts.map +0 -1
- package/dist/lib/encryption-key-service.js +0 -272
- package/dist/lib/encryption-key-service.js.map +0 -1
- package/dist/lib/followers-handler.d.ts +0 -21
- package/dist/lib/followers-handler.d.ts.map +0 -1
- package/dist/lib/followers-handler.js +0 -35
- package/dist/lib/followers-handler.js.map +0 -1
- package/dist/lib/routes/followers.d.ts +0 -6
- package/dist/lib/routes/followers.d.ts.map +0 -1
- package/dist/lib/routes/followers.js +0 -405
- 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
|
+
}
|