Consent is bound to the channel it was given for: approving a post on X says nothing about Instagram. Publishing state moves from the SocialMilestone row into SocialMilestonePost, one row per channel, where a missing row means no consent. The dialog asks per channel, shows the text each one will publish and keeps a separate handle for each; Instagram captions end in hashtags because a link there is not clickable. Also fixes three problems in the existing X path: - A QR code already past several thresholds produced one prompt per threshold, and since the post quotes the current scan count, every one of them would have published the same number. Only the highest threshold is announced now. - Detection ran after every unique scan and re-read the QR code's full scan history just to hit skipDuplicates. Known milestones are filtered first. - A failed post stayed failed forever because the consent dialog only opens once. The queue now retries three times on its own, spaces first attempts by SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per channel with restart and revoke. The worker no longer renders the card itself; it downloads the image the app renders at /s/m/<token>/og, which also serves the new square and portrait formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and SOCIAL_WORKER_CHANNELS both name it. Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before deploying this version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
355 lines
9.4 KiB
Plaintext
355 lines
9.4 KiB
Plaintext
// This is your Prisma schema file,
|
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String?
|
|
password String?
|
|
image String?
|
|
emailVerified DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// Stripe subscription fields
|
|
stripeCustomerId String? @unique
|
|
stripeSubscriptionId String? @unique
|
|
stripePriceId String?
|
|
stripeCurrentPeriodEnd DateTime?
|
|
plan Plan @default(FREE)
|
|
|
|
// Password reset fields
|
|
resetPasswordToken String? @unique
|
|
resetPasswordExpires DateTime?
|
|
|
|
// Retention email tracking
|
|
activationNudgeSentAt DateTime?
|
|
upgradeNudgeSentAt DateTime?
|
|
thirtyDayNudgeSentAt DateTime?
|
|
limitReachedNudgeSentAt DateTime?
|
|
firstScanNudgeSentAt DateTime?
|
|
qrPulseSentAt DateTime?
|
|
|
|
/// When the user last looked at their own scan numbers. A live session is not
|
|
/// the same as someone having seen a number, so this is what "inactive" means.
|
|
lastAnalyticsViewAt DateTime?
|
|
|
|
// RevOps attribution
|
|
signupSource String?
|
|
signupSourceSelfReported String?
|
|
signupMedium String?
|
|
signupCampaign String?
|
|
signupContent String?
|
|
signupTerm String?
|
|
signupReferrer String?
|
|
signupLandingPath String?
|
|
signupFirstSeenAt DateTime?
|
|
emailDomain String?
|
|
|
|
// Onboarding and qualification
|
|
primaryUseCase String?
|
|
primaryGoal String?
|
|
jobRole String?
|
|
companyName String?
|
|
companyWebsite String?
|
|
teamSizeBucket String?
|
|
onboardingStartedAt DateTime?
|
|
sourceConfirmedAt DateTime?
|
|
useCaseSelectedAt DateTime?
|
|
goalSelectedAt DateTime?
|
|
profileCompletedAt DateTime?
|
|
firstQrCreatedAt DateTime?
|
|
firstDynamicQrAt DateTime?
|
|
firstStaticQrAt DateTime?
|
|
firstScanAt DateTime?
|
|
activationAt DateTime?
|
|
onboardingCompletedAt DateTime?
|
|
|
|
// RevOps scoring
|
|
fitScore Int @default(0)
|
|
intentScore Int @default(0)
|
|
leadScore Int @default(0)
|
|
lifecycleStage String @default("cold")
|
|
lastQualifiedAt DateTime?
|
|
lastScoredAt DateTime?
|
|
|
|
qrCodes QRCode[]
|
|
designPresets QRDesignPreset[]
|
|
integrations Integration[]
|
|
accounts Account[]
|
|
sessions Session[]
|
|
lifecycleLogs UserLifecycleLog[]
|
|
socialMilestones SocialMilestone[]
|
|
|
|
// Social-success sharing preferences. A post is still never published
|
|
// without a per-milestone approval stored below.
|
|
xHandle String?
|
|
instagramHandle String?
|
|
socialPromptOptOut Boolean @default(false)
|
|
}
|
|
|
|
enum Plan {
|
|
FREE
|
|
PRO
|
|
BUSINESS
|
|
}
|
|
|
|
model Account {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
type String
|
|
provider String
|
|
providerAccountId String
|
|
refresh_token String? @db.Text
|
|
access_token String? @db.Text
|
|
expires_at Int?
|
|
token_type String?
|
|
scope String?
|
|
id_token String? @db.Text
|
|
session_state String?
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([provider, providerAccountId])
|
|
}
|
|
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
sessionToken String @unique
|
|
userId String
|
|
expires DateTime
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model VerificationToken {
|
|
identifier String
|
|
token String @unique
|
|
expires DateTime
|
|
|
|
@@unique([identifier, token])
|
|
}
|
|
|
|
model QRCode {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
title String
|
|
type QRType @default(DYNAMIC)
|
|
contentType ContentType @default(URL)
|
|
content Json
|
|
tags String[]
|
|
status QRStatus @default(ACTIVE)
|
|
style Json
|
|
slug String @unique
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
scans QRScan[]
|
|
socialMilestones SocialMilestone[]
|
|
|
|
@@index([userId, createdAt])
|
|
@@index([userId, type, status])
|
|
}
|
|
|
|
model SocialMilestone {
|
|
id String @id @default(cuid())
|
|
qrId String
|
|
userId String
|
|
kind String
|
|
status String @default("detected")
|
|
detectedAt DateTime @default(now())
|
|
shownAt DateTime?
|
|
respondedAt DateTime?
|
|
claimedAt DateTime?
|
|
postedAt DateTime?
|
|
withName Boolean @default(false)
|
|
consentText String?
|
|
language String @default("en")
|
|
cardData Json?
|
|
brandStatus String @default("pending")
|
|
brandApprovedAt DateTime?
|
|
brandPostedAt DateTime?
|
|
brandPostUrl String?
|
|
brandPostError String?
|
|
selfSharedAt DateTime?
|
|
shareToken String? @unique
|
|
publicShareApprovedAt DateTime?
|
|
attempts Int @default(0)
|
|
nextAttemptAt DateTime?
|
|
|
|
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
posts SocialMilestonePost[]
|
|
|
|
@@unique([qrId, kind])
|
|
@@index([status, respondedAt])
|
|
@@index([status, claimedAt])
|
|
@@index([userId, status])
|
|
@@index([brandStatus, brandApprovedAt])
|
|
}
|
|
|
|
model SocialMilestonePost {
|
|
id String @id @default(cuid())
|
|
milestoneId String
|
|
/// "x" | "instagram". Consent is bound to the channel it was given for.
|
|
channel String
|
|
/// approved | processing | posted | failed | revoked
|
|
status String @default("approved")
|
|
/// The exact text the customer read before consenting.
|
|
consentText String
|
|
handle String?
|
|
approvedAt DateTime @default(now())
|
|
claimedAt DateTime?
|
|
postedAt DateTime?
|
|
postUrl String?
|
|
error String?
|
|
attempts Int @default(0)
|
|
nextAttemptAt DateTime?
|
|
|
|
milestone SocialMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([milestoneId, channel])
|
|
@@index([channel, status, approvedAt])
|
|
}
|
|
|
|
enum QRType {
|
|
STATIC
|
|
DYNAMIC
|
|
}
|
|
|
|
enum ContentType {
|
|
URL
|
|
VCARD
|
|
GEO
|
|
PHONE
|
|
SMS
|
|
TEXT
|
|
WHATSAPP
|
|
PDF
|
|
APP
|
|
COUPON
|
|
FEEDBACK
|
|
BARCODE
|
|
}
|
|
|
|
enum QRStatus {
|
|
ACTIVE
|
|
PAUSED
|
|
}
|
|
|
|
model QRScan {
|
|
id String @id @default(cuid())
|
|
qrId String
|
|
ts DateTime @default(now())
|
|
ipHash String
|
|
userAgent String?
|
|
device String?
|
|
os String?
|
|
country String?
|
|
referrer String?
|
|
utmSource String?
|
|
utmMedium String?
|
|
utmCampaign String?
|
|
isUnique Boolean @default(false)
|
|
|
|
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([qrId, ts])
|
|
}
|
|
|
|
model Integration {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
provider String
|
|
status String @default("inactive")
|
|
config Json
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model TiktokIntegration {
|
|
id String @id @default(cuid())
|
|
accountKey String @unique
|
|
openId String
|
|
accessToken String
|
|
refreshToken String
|
|
scope String?
|
|
accessTokenExpiresAt DateTime
|
|
refreshTokenExpiresAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model SocialAsset {
|
|
id String @id @default(cuid())
|
|
filename String
|
|
mimeType String
|
|
data Bytes
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
/// Saved design presets. The point for an agency is not the star shape, it is
|
|
/// that client A looks identical across 500 codes.
|
|
model QRDesignPreset {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
name String
|
|
style Json
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, name])
|
|
@@index([userId])
|
|
}
|
|
|
|
model UserLifecycleLog {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
fromStage String?
|
|
toStage String
|
|
fitScore Int @default(0)
|
|
intentScore Int @default(0)
|
|
leadScore Int @default(0)
|
|
reason String?
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model NewsletterSubscription {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
source String @default("ai-coming-soon")
|
|
status String @default("subscribed")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([email])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
model Lead {
|
|
id String @id @default(cuid())
|
|
email String
|
|
source String @default("reprint-calculator")
|
|
reprintCost Float?
|
|
updatesPerYear Float?
|
|
annualSavings Float?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|