diff --git a/.env.sample b/.env.sample index 743b8e18..958074f7 100644 --- a/.env.sample +++ b/.env.sample @@ -9,8 +9,13 @@ NODE_ENV=development STRIPE_SECRET_KEY=your_stripe_secret_key STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret -STRIPE_PRICE_ID=your_stripe_price_id +STRIPE_PRICE_ID=your_stripe_price_id # likely no longer needed as we use dynamic payment intent STRIPE_DISCOUNT_COUPON_ID=your_stripe_discount_coupon_id +STRIPE_CLIENT_ID=your_oauth2_id_for_connect + +# Token signing secret for unsubscribe links and other signed tokens +# Generate with: openssl rand -base64 32 +TOKEN_SIGNING_SECRET=your_token_signing_secret R2_ENDPOINT= R2_ACCESS_KEY_ID= @@ -30,12 +35,4 @@ AWS_SES_ACCESS_KEY_ID= AWS_SES_SECRET_ACCESS_KEY= AWS_SES_REGION=us-east-1 -OPENAI_API_KEY= -# ============================================================================ -# Dev Patches (optional - for local development only) -# ============================================================================ -# These variables enable dev-only features via the .dev patch system. -# See .dev/README.md for details on available patches. -# -# DEV_BYPASS_AUTH=true # Skip Google OAuth, use dev login menu instead -# DEV_MOCK_STORAGE=true # Use mock storage instead of R2/S3 +OPENAI_API_KEY= \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a7d44728..1df4de87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,40 @@ export const toggleEarlyAccessModeFn = createServerFn({ }).middleware([unauthenticatedMiddleware]); ``` +### Server Function Response Format Convention + +**All server functions must return responses wrapped in `{ success: true, data: ... }`** for consistency: + +```typescript +// CORRECT - always wrap in { success, data } +export const getItemsFn = createServerFn({ method: "GET" }) + .handler(async () => { + const items = await getItems(); + return { success: true, data: items }; + }); + +// WRONG - never return raw data +export const getItemsFn = createServerFn({ method: "GET" }) + .handler(async () => { + const items = await getItems(); + return items; // DON'T DO THIS + }); +``` + +When consuming server functions, always destructure the `data` property: + +```typescript +// In loaders +const { data: items } = await getItemsFn(); + +// In useQuery +const { data: response } = useQuery({ + queryKey: ["items"], + queryFn: () => getItemsFn(), +}); +const items = response?.data; +``` + ## DO NOT RUN SERVER I always run my server in a separate terminal. NEVER TRY TO RUN `npm run dev`! diff --git a/docs/features/affiliates/readme.md b/docs/features/affiliates/readme.md index 81b4a582..6c04588b 100644 --- a/docs/features/affiliates/readme.md +++ b/docs/features/affiliates/readme.md @@ -2,7 +2,7 @@ ## Overview -The Affiliate Program allows users to earn 30% commission by referring new customers to purchase the Agentic Jumpstart course. The system now includes a GDPR-compliant discount system where affiliate codes provide customers with 10% discounts while maintaining affiliate tracking through Stripe metadata. This feature includes a complete affiliate management system with tracking, analytics, and payout management. +The Affiliate Program allows users to earn commission (configurable by admin, default 30%) by referring new customers to purchase the Agentic Jumpstart course. The system now includes a GDPR-compliant discount system where affiliate codes provide customers with 10% discounts while maintaining affiliate tracking through Stripe metadata. This feature includes a complete affiliate management system with tracking, analytics, and payout management. ## Quick Links @@ -23,7 +23,7 @@ The Affiliate Program allows users to earn 30% commission by referring new custo 1. Navigate to [/affiliates](http://localhost:4000/affiliates) 2. If not logged in, you'll see the enhanced landing page with: - Modern gradient backgrounds and animations - - Program benefits overview (30% commission, 30-day cookies, real-time tracking) + - Program benefits overview (configurable commission, 30-day cookies, real-time tracking) - "How It Works" 4-step process visualization 3. Click "Login to Join Program" and authenticate with Google 4. Once logged in, you'll see the registration form with: @@ -90,8 +90,8 @@ The enhanced affiliate dashboard displays real-time statistics with improved vis #### Main Statistics Cards -- **Total Earnings**: Lifetime commission earned (30% of all referral sales) -- **Unpaid Balance**: Pending payment amount (minimum $50 payout threshold) +- **Total Earnings**: Lifetime commission earned (configurable % of all referral sales) +- **Unpaid Balance**: Pending payment amount (minimum payout threshold applies to Payment Link affiliates only) - **Total Referrals**: Number of successful conversions tracked - **Paid Out**: Amount already paid via recorded payouts @@ -130,7 +130,7 @@ The enhanced affiliate dashboard displays real-time statistics with improved vis - **Activate/Deactivate**: Toggle affiliate status - **Record Payout**: 1. Click "Record Payout" for an affiliate - 2. Enter amount (minimum $50) + 2. Enter amount (minimum threshold applies for Payment Link affiliates) 3. Select payment method 4. Add transaction ID (optional) 5. Add notes (optional) @@ -162,17 +162,19 @@ VALUES (1, 2, 'cs_test_123', 20000, 6000); ## Configuration -Settings are defined in `/src/config.ts`: +Default settings are defined in `/src/config.ts`: ```typescript AFFILIATE_CONFIG = { - COMMISSION_RATE: 30, // 30% commission - MINIMUM_PAYOUT: 5000, // $50 minimum + DEFAULT_COMMISSION_RATE: 30, // Default 30% commission (configurable via admin) + DEFAULT_MINIMUM_PAYOUT: 5000, // Default $50 minimum (Payment Link affiliates only, configurable via admin) AFFILIATE_CODE_LENGTH: 8, // Code length AFFILIATE_CODE_RETRY_ATTEMPTS: 10, }; ``` +**Note**: Commission rate and minimum payout are configurable via the Admin Dashboard at `/admin/affiliates`. The minimum payout setting only appears when the `AFFILIATE_CUSTOM_PAYMENT_LINK` feature flag is enabled (Stripe Connect affiliates have no minimum threshold). + ### Environment Variables New environment variable for discount system: @@ -229,14 +231,14 @@ The affiliate system integrates with Stripe in multiple ways: #### Commission System (REQ-AF-013 to REQ-AF-016) -- 30% commission rate calculation accuracy +- Configurable commission rate calculation accuracy (default 30%) - Net sale price commission calculation - Cents-based storage to avoid floating point issues - Self-referral prevention mechanism #### Payment & Payouts (REQ-AF-017 to REQ-AF-021) -- $50 minimum payout threshold enforcement +- Configurable minimum payout threshold (Payment Link affiliates only, default $50) - Monthly payout schedule management - Payment link update functionality - Admin payout recording with transaction details @@ -338,7 +340,7 @@ npm run test:affiliate:admin #### Commission calculation errors - Verify amounts are stored in cents (integers) -- Check commission rate is exactly 30% +- Check commission rate matches the configured value in admin settings - Ensure no floating point precision issues - **Test Scenario**: REQ-AF-013 covers commission calculations @@ -400,6 +402,239 @@ To debug affiliate discount and tracking: - `/src/utils/env.ts` - Added STRIPE_DISCOUNT_COUPON_ID environment variable - `/src/fn/affiliates.ts` - Contains validateAffiliateCodeFn for real-time validation +## Stripe Connect Integration + +### Overview + +Stripe Connect enables automatic payouts to affiliates who connect their Stripe account. Instead of manually tracking payment links and processing payouts, affiliates with connected Stripe accounts receive automatic transfers when their balance reaches the minimum threshold. + +**How It Works:** +1. Affiliate connects their Stripe account via OAuth flow +2. System tracks their earnings as usual +3. When there's any positive unpaid balance, admin can trigger automatic payout (no minimum for Stripe Connect) +4. Funds are transferred directly to the affiliate's Stripe account +5. Affiliate receives funds according to their Stripe payout schedule + +### Account Status Lifecycle + +Affiliates can choose between two payment methods: +- **Payment Link**: Manual payouts via PayPal, Venmo, or other payment services +- **Stripe Connect**: Automatic payouts directly to connected Stripe account + +#### Stripe Account Statuses + +| Status | Description | Can Receive Payouts? | +|--------|-------------|---------------------| +| `not_started` | No Stripe account connected | No | +| `onboarding` | Account created but setup incomplete | No | +| `pending` | Details submitted, awaiting Stripe verification | No | +| `active` | Fully verified, charges and payouts enabled | Yes | +| `restricted` | Account has restrictions or compliance issues | No | + +### Connecting a Stripe Account (Affiliate Guide) + +#### Step-by-Step Process + +1. **Navigate to Dashboard**: Go to [/affiliate-dashboard](http://localhost:4000/affiliate-dashboard) +2. **Select Payment Method**: Choose "Stripe Connect" as your payment method +3. **Initiate Connection**: Click "Connect with Stripe" button +4. **Complete Stripe Onboarding**: You'll be redirected to Stripe's secure onboarding flow + - Provide business/personal information + - Verify identity + - Set up bank account for payouts +5. **Return to Dashboard**: After completing onboarding, you're redirected back +6. **Verify Status**: Your account status should show "Active" for automatic payouts + +#### OAuth Flow Details + +The Stripe Connect integration uses OAuth with Express accounts: + +``` +User clicks "Connect" → /api/connect/stripe (creates account, generates link) + ↓ +User completes Stripe onboarding (hosted by Stripe) + ↓ +Stripe redirects → /api/connect/stripe/callback (updates status) + ↓ +User returns to affiliate dashboard with connected account +``` + +**Security Features:** +- CSRF protection via state tokens stored in HTTP-only cookies +- 10-minute expiration on onboarding sessions +- Double verification of affiliate ID on callback +- Secure cookie settings in production + +### Automatic Payouts + +#### Eligibility Requirements + +For an affiliate to receive automatic payouts: +1. **Active affiliate account** - Account must not be deactivated +2. **Connected Stripe account** - Must have `stripeConnectAccountId` set +3. **Payouts enabled** - Stripe account status must be `active` with `stripePayoutsEnabled: true` +4. **Positive balance** - Any positive unpaid balance (no minimum threshold for Stripe Connect) + +#### Payout Process + +**Single Affiliate Payout:** +1. Admin triggers payout for specific affiliate +2. System validates eligibility +3. Creates Stripe Transfer to connected account +4. Records payout in database +5. Updates affiliate balances + +**Batch Payout Processing:** +1. Admin triggers "Process All Automatic Payouts" +2. System queries all eligible affiliates +3. Processes payouts in batches of 3 (respects Stripe rate limits) +4. 1-second delay between batches +5. Returns summary of successful/failed payouts + +#### Rate Limiting + +The batch payout system implements controlled concurrency: +- **Concurrent payouts**: 3 at a time +- **Batch delay**: 1 second between batches +- **Idempotency**: Duplicate transfer detection prevents double payouts + +### Disconnecting Stripe Account + +To switch back to manual payment links: +1. Go to affiliate dashboard +2. Change payment method from "Stripe Connect" to "Payment Link" +3. Enter your PayPal or other payment link +4. Save changes + +**Note**: The Stripe account remains in the system but is no longer used for payouts. To fully disconnect the Stripe account, contact support. + +### Admin Functions for Stripe Connect + +#### Viewing Stripe Connect Status + +The admin affiliate dashboard displays: +- Payment method (link vs stripe) +- Stripe account status +- Charges enabled flag +- Payouts enabled flag +- Last sync timestamp + +#### Manual Status Sync + +If an affiliate's status appears outdated: +1. The system automatically syncs when `account.updated` webhooks are received +2. Affiliates can manually refresh from their dashboard +3. Admins can view the `lastStripeSync` timestamp + +#### Processing Automatic Payouts + +**Individual Payout:** +``` +Admin Dashboard → Select Affiliate → "Process Automatic Payout" +``` + +**Batch Processing:** +``` +Admin Dashboard → "Process All Automatic Payouts" → Review Results +``` + +### Troubleshooting + +#### "Stripe payouts not enabled for this affiliate" + +**Cause**: The affiliate's Stripe account is not fully set up. + +**Solutions**: +1. Check account status - should be `active` +2. Have affiliate complete Stripe onboarding +3. Verify identity documents if requested by Stripe +4. Check for any restrictions in Stripe dashboard + +#### "Balance below minimum payout" + +**Cause**: Payment Link affiliate's unpaid balance is less than the configured minimum. + +**Solution**: Wait for more referral conversions until balance reaches the minimum threshold. Note: Stripe Connect affiliates have no minimum threshold and can receive payouts for any positive balance. + +#### "No affiliate found with this Stripe account ID" + +**Cause**: Webhook received for unknown account. + +**Solution**: This is normal for accounts not in your system. No action needed. + +#### Affiliate stuck in "onboarding" status + +**Cause**: User didn't complete Stripe onboarding flow. + +**Solutions**: +1. Have affiliate click "Connect with Stripe" again +2. They'll be redirected to continue where they left off +3. Ensure they complete all required steps in Stripe + +#### Payout failed with Stripe error + +**Common causes**: +- Insufficient platform balance +- Connected account restricted +- Bank account issues on affiliate's side + +**Solutions**: +1. Check Stripe dashboard for detailed error +2. Contact affiliate to resolve account issues +3. Retry payout after issue is resolved + +### Environment Variables + +Required environment variables for Stripe Connect: + +```bash +# Core Stripe configuration (already required) +STRIPE_SECRET_KEY=sk_live_xxx # Your Stripe secret key +STRIPE_WEBHOOK_SECRET=whsec_xxx # Webhook signing secret + +# Application URL (used for OAuth redirects) +HOST_NAME=https://yourdomain.com + +# REQUIRED: System user ID for automatic payouts +# This MUST be set to a dedicated system/admin user ID in the database +# The application will fail to start if this is not set or is invalid +SYSTEM_USER_ID=123 +``` + +**Important**: `SYSTEM_USER_ID` is a required environment variable. It must be set to a dedicated system/admin user ID that exists in your database. This user ID is recorded as the "processedBy" user when automatic affiliate payouts are triggered. Do not use a real user's ID - create a dedicated system account for this purpose. + +**Note**: No additional Stripe Connect-specific environment variables are required. The integration uses your existing `STRIPE_SECRET_KEY` which must have Connect permissions. + +### Webhook Configuration + +The system handles the `account.updated` webhook event to sync Stripe account status changes. Ensure your Stripe webhook endpoint is configured to receive Connect events: + +1. Go to Stripe Dashboard → Webhooks +2. Add endpoint: `https://yourdomain.com/api/webhooks/stripe` +3. Select events: `account.updated` (for Connect accounts) + +### API Routes Reference + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/connect/stripe` | GET | Initiates Stripe Connect OAuth flow | +| `/api/connect/stripe/callback` | GET | Handles OAuth callback, updates status | +| `/api/connect/stripe/refresh` | GET | Regenerates onboarding link for incomplete setup | + +### Database Fields for Stripe Connect + +The `app_affiliate` table includes these Stripe Connect fields: + +| Field | Type | Description | +|-------|------|-------------| +| `paymentMethod` | enum | `link` or `stripe` | +| `stripeConnectAccountId` | string | Stripe Express account ID (acct_xxx) | +| `stripeAccountStatus` | string | Account status (not_started, onboarding, pending, active, restricted) | +| `stripeChargesEnabled` | boolean | Whether account can receive charges | +| `stripePayoutsEnabled` | boolean | Whether account can receive payouts | +| `stripeDetailsSubmitted` | boolean | Whether onboarding details are submitted | +| `lastStripeSync` | timestamp | Last time status was synced from Stripe | + ## Support For issues or questions about the affiliate program, contact the development team or check the main project documentation. diff --git a/drizzle/0050_gorgeous_kulan_gath.sql b/drizzle/0050_gorgeous_kulan_gath.sql new file mode 100644 index 00000000..b6a5ddca --- /dev/null +++ b/drizzle/0050_gorgeous_kulan_gath.sql @@ -0,0 +1,28 @@ +CREATE TYPE "public"."affiliate_payout_status_enum" AS ENUM('pending', 'completed', 'failed');--> statement-breakpoint +ALTER TABLE "app_affiliate" ALTER COLUMN "paymentLink" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "app_segment" ALTER COLUMN "moduleId" SET DATA TYPE integer;--> statement-breakpoint +ALTER TABLE "app_affiliate_payout" ADD COLUMN "stripeTransferId" text;--> statement-breakpoint +ALTER TABLE "app_affiliate_payout" ADD COLUMN "status" "affiliate_payout_status_enum" DEFAULT 'completed' NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate_payout" ADD COLUMN "errorMessage" text;--> statement-breakpoint +ALTER TABLE "app_affiliate_referral" ADD COLUMN "commissionRate" integer;--> statement-breakpoint +ALTER TABLE "app_affiliate_referral" ADD COLUMN "discountRate" integer;--> statement-breakpoint +ALTER TABLE "app_affiliate_referral" ADD COLUMN "originalCommissionRate" integer;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "paymentMethod" text DEFAULT 'link' NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "discountRate" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "stripeConnectAccountId" text;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "stripeAccountStatus" text DEFAULT 'not_started' NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "stripeChargesEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "stripePayoutsEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "stripeDetailsSubmitted" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "stripeAccountType" text;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "lastStripeSync" timestamp;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "lastPayoutError" text;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "lastPayoutErrorAt" timestamp;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "lastPayoutAttemptAt" timestamp;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "lastConnectAttemptAt" timestamp;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "connectAttemptCount" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "payoutRetryCount" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD COLUMN "nextPayoutRetryAt" timestamp;--> statement-breakpoint +CREATE UNIQUE INDEX "payouts_stripe_transfer_idx" ON "app_affiliate_payout" USING btree ("stripeTransferId");--> statement-breakpoint +CREATE INDEX "payouts_status_idx" ON "app_affiliate_payout" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "affiliates_stripe_account_idx" ON "app_affiliate" USING btree ("stripeConnectAccountId"); \ No newline at end of file diff --git a/drizzle/0051_chilly_the_watchers.sql b/drizzle/0051_chilly_the_watchers.sql new file mode 100644 index 00000000..6a769633 --- /dev/null +++ b/drizzle/0051_chilly_the_watchers.sql @@ -0,0 +1,2 @@ +DROP INDEX "affiliates_code_idx";--> statement-breakpoint +CREATE INDEX "referrals_affiliate_unpaid_idx" ON "app_affiliate_referral" USING btree ("affiliateId","isPaid"); \ No newline at end of file diff --git a/drizzle/0052_funny_hardball.sql b/drizzle/0052_funny_hardball.sql new file mode 100644 index 00000000..166e21a9 --- /dev/null +++ b/drizzle/0052_funny_hardball.sql @@ -0,0 +1,7 @@ +CREATE TYPE "public"."affiliate_payment_method_enum" AS ENUM('link', 'stripe');--> statement-breakpoint +CREATE TYPE "public"."stripe_account_status_enum" AS ENUM('not_started', 'onboarding', 'active', 'restricted');--> statement-breakpoint +ALTER TABLE "app_affiliate" ALTER COLUMN "paymentMethod" SET DEFAULT 'link'::"public"."affiliate_payment_method_enum";--> statement-breakpoint +ALTER TABLE "app_affiliate" ALTER COLUMN "paymentMethod" SET DATA TYPE "public"."affiliate_payment_method_enum" USING "paymentMethod"::"public"."affiliate_payment_method_enum";--> statement-breakpoint +ALTER TABLE "app_affiliate" ALTER COLUMN "stripeAccountStatus" SET DEFAULT 'not_started'::"public"."stripe_account_status_enum";--> statement-breakpoint +ALTER TABLE "app_affiliate" ALTER COLUMN "stripeAccountStatus" SET DATA TYPE "public"."stripe_account_status_enum" USING "stripeAccountStatus"::"public"."stripe_account_status_enum";--> statement-breakpoint +ALTER TABLE "app_affiliate" ADD CONSTRAINT "discount_rate_check" CHECK ("app_affiliate"."discountRate" <= "app_affiliate"."commissionRate" AND "app_affiliate"."discountRate" >= 0); \ No newline at end of file diff --git a/drizzle/meta/0050_snapshot.json b/drizzle/meta/0050_snapshot.json new file mode 100644 index 00000000..fcc65a31 --- /dev/null +++ b/drizzle/meta/0050_snapshot.json @@ -0,0 +1,4507 @@ +{ + "id": "f11ff12d-4fa3-411f-b174-9469d200fbfb", + "prevId": "d81c06ec-eea5-4887-8648-65c961ba583a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_accounts": { + "name": "app_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_id_google_id_idx": { + "name": "user_id_google_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "googleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_accounts_userId_app_user_id_fk": { + "name": "app_accounts_userId_app_user_id_fk", + "tableFrom": "app_accounts", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_accounts_googleId_unique": { + "name": "app_accounts_googleId_unique", + "nullsNotDistinct": false, + "columns": [ + "googleId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate_payout": { + "name": "app_affiliate_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "affiliateId": { + "name": "affiliateId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paymentMethod": { + "name": "paymentMethod", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transactionId": { + "name": "transactionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeTransferId": { + "name": "stripeTransferId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "affiliate_payout_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paidBy": { + "name": "paidBy", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payouts_affiliate_paid_idx": { + "name": "payouts_affiliate_paid_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "paid_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_stripe_transfer_idx": { + "name": "payouts_stripe_transfer_idx", + "columns": [ + { + "expression": "stripeTransferId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_status_idx": { + "name": "payouts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_payout_affiliateId_app_affiliate_id_fk": { + "name": "app_affiliate_payout_affiliateId_app_affiliate_id_fk", + "tableFrom": "app_affiliate_payout", + "tableTo": "app_affiliate", + "columnsFrom": [ + "affiliateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_affiliate_payout_paidBy_app_user_id_fk": { + "name": "app_affiliate_payout_paidBy_app_user_id_fk", + "tableFrom": "app_affiliate_payout", + "tableTo": "app_user", + "columnsFrom": [ + "paidBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate_referral": { + "name": "app_affiliate_referral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "affiliateId": { + "name": "affiliateId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "purchaserId": { + "name": "purchaserId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "stripeSessionId": { + "name": "stripeSessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commissionRate": { + "name": "commissionRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "discountRate": { + "name": "discountRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "originalCommissionRate": { + "name": "originalCommissionRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "isPaid": { + "name": "isPaid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referrals_affiliate_created_idx": { + "name": "referrals_affiliate_created_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_purchaser_idx": { + "name": "referrals_purchaser_idx", + "columns": [ + { + "expression": "purchaserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_stripe_session_unique": { + "name": "referrals_stripe_session_unique", + "columns": [ + { + "expression": "stripeSessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_referral_affiliateId_app_affiliate_id_fk": { + "name": "app_affiliate_referral_affiliateId_app_affiliate_id_fk", + "tableFrom": "app_affiliate_referral", + "tableTo": "app_affiliate", + "columnsFrom": [ + "affiliateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_affiliate_referral_purchaserId_app_user_id_fk": { + "name": "app_affiliate_referral_purchaserId_app_user_id_fk", + "tableFrom": "app_affiliate_referral", + "tableTo": "app_user", + "columnsFrom": [ + "purchaserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate": { + "name": "app_affiliate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "affiliateCode": { + "name": "affiliateCode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paymentMethod": { + "name": "paymentMethod", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'link'" + }, + "paymentLink": { + "name": "paymentLink", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commissionRate": { + "name": "commissionRate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "discountRate": { + "name": "discountRate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "totalEarnings": { + "name": "totalEarnings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paidAmount": { + "name": "paidAmount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unpaidBalance": { + "name": "unpaidBalance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "stripeConnectAccountId": { + "name": "stripeConnectAccountId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeAccountStatus": { + "name": "stripeAccountStatus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "stripeChargesEnabled": { + "name": "stripeChargesEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripePayoutsEnabled": { + "name": "stripePayoutsEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeDetailsSubmitted": { + "name": "stripeDetailsSubmitted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeAccountType": { + "name": "stripeAccountType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastStripeSync": { + "name": "lastStripeSync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPayoutError": { + "name": "lastPayoutError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastPayoutErrorAt": { + "name": "lastPayoutErrorAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPayoutAttemptAt": { + "name": "lastPayoutAttemptAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastConnectAttemptAt": { + "name": "lastConnectAttemptAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connectAttemptCount": { + "name": "connectAttemptCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payoutRetryCount": { + "name": "payoutRetryCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "nextPayoutRetryAt": { + "name": "nextPayoutRetryAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "affiliates_user_id_idx": { + "name": "affiliates_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "affiliates_code_idx": { + "name": "affiliates_code_idx", + "columns": [ + { + "expression": "affiliateCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "affiliates_stripe_account_idx": { + "name": "affiliates_stripe_account_idx", + "columns": [ + { + "expression": "stripeConnectAccountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_userId_app_user_id_fk": { + "name": "app_affiliate_userId_app_user_id_fk", + "tableFrom": "app_affiliate", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_affiliate_userId_unique": { + "name": "app_affiliate_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "app_affiliate_affiliateCode_unique": { + "name": "app_affiliate_affiliateCode_unique", + "nullsNotDistinct": false, + "columns": [ + "affiliateCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_agent": { + "name": "app_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_author_idx": { + "name": "agents_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_type_idx": { + "name": "agents_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_public_idx": { + "name": "agents_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_slug_idx": { + "name": "agents_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_agent_author_id_app_user_id_fk": { + "name": "app_agent_author_id_app_user_id_fk", + "tableFrom": "app_agent", + "tableTo": "app_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_agent_name_unique": { + "name": "app_agent_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_agent_slug_unique": { + "name": "app_agent_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_analytics_event": { + "name": "app_analytics_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pagePath": { + "name": "pagePath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ipAddressHash": { + "name": "ipAddressHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analytics_events_session_idx": { + "name": "analytics_events_session_idx", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_type_idx": { + "name": "analytics_events_type_idx", + "columns": [ + { + "expression": "eventType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_created_idx": { + "name": "analytics_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_analytics_session": { + "name": "app_analytics_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrerSource": { + "name": "referrerSource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageViews": { + "name": "pageViews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hasPurchaseIntent": { + "name": "hasPurchaseIntent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasConversion": { + "name": "hasConversion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "analytics_sessions_first_seen_idx": { + "name": "analytics_sessions_first_seen_idx", + "columns": [ + { + "expression": "first_seen", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_sessions_gclid_idx": { + "name": "analytics_sessions_gclid_idx", + "columns": [ + { + "expression": "gclid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_app_setting": { + "name": "app_app_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_settings_key_idx": { + "name": "app_settings_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_attachment": { + "name": "app_attachment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_segment_created_idx": { + "name": "attachments_segment_created_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_attachment_segmentId_app_segment_id_fk": { + "name": "app_attachment_segmentId_app_segment_id_fk", + "tableFrom": "app_attachment", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_blog_post_view": { + "name": "app_blog_post_view", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "blogPostId": { + "name": "blogPostId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ipAddressHash": { + "name": "ipAddressHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blog_post_views_post_idx": { + "name": "blog_post_views_post_idx", + "columns": [ + { + "expression": "blogPostId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_session_idx": { + "name": "blog_post_views_session_idx", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_created_idx": { + "name": "blog_post_views_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_session_post_unique": { + "name": "blog_post_views_session_post_unique", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blogPostId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_blog_post_view_blogPostId_app_blog_post_id_fk": { + "name": "app_blog_post_view_blogPostId_app_blog_post_id_fk", + "tableFrom": "app_blog_post_view", + "tableTo": "app_blog_post", + "columnsFrom": [ + "blogPostId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_blog_post": { + "name": "app_blog_post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublished": { + "name": "isPublished", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "authorId": { + "name": "authorId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "featuredImage": { + "name": "featuredImage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_posts_slug_idx": { + "name": "blog_posts_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_author_idx": { + "name": "blog_posts_author_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_published_idx": { + "name": "blog_posts_published_idx", + "columns": [ + { + "expression": "isPublished", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_created_idx": { + "name": "blog_posts_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_blog_post_authorId_app_user_id_fk": { + "name": "app_blog_post_authorId_app_user_id_fk", + "tableFrom": "app_blog_post", + "tableTo": "app_user", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_blog_post_slug_unique": { + "name": "app_blog_post_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_comment": { + "name": "app_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "repliedToId": { + "name": "repliedToId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_segment_created_idx": { + "name": "comments_segment_created_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_user_created_idx": { + "name": "comments_user_created_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_idx": { + "name": "comments_parent_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_replied_to_idx": { + "name": "comments_replied_to_idx", + "columns": [ + { + "expression": "repliedToId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_comment_userId_app_user_id_fk": { + "name": "app_comment_userId_app_user_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_segmentId_app_segment_id_fk": { + "name": "app_comment_segmentId_app_segment_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_parentId_app_comment_id_fk": { + "name": "app_comment_parentId_app_comment_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_comment", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_repliedToId_app_user_id_fk": { + "name": "app_comment_repliedToId_app_user_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_user", + "columnsFrom": [ + "repliedToId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_email_batch": { + "name": "app_email_batch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipientCount": { + "name": "recipientCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sentCount": { + "name": "sentCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failedCount": { + "name": "failedCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "adminId": { + "name": "adminId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_batches_admin_created_idx": { + "name": "email_batches_admin_created_idx", + "columns": [ + { + "expression": "adminId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_batches_status_idx": { + "name": "email_batches_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_email_batch_adminId_app_user_id_fk": { + "name": "app_email_batch_adminId_app_user_id_fk", + "tableFrom": "app_email_batch", + "tableTo": "app_user", + "columnsFrom": [ + "adminId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_email_template": { + "name": "app_email_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updatedBy": { + "name": "updatedBy", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_templates_key_idx": { + "name": "email_templates_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_templates_active_idx": { + "name": "email_templates_active_idx", + "columns": [ + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_email_template_updatedBy_app_user_id_fk": { + "name": "app_email_template_updatedBy_app_user_id_fk", + "tableFrom": "app_email_template", + "tableTo": "app_user", + "columnsFrom": [ + "updatedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_email_template_key_unique": { + "name": "app_email_template_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_feature_flag_target": { + "name": "app_feature_flag_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_mode": { + "name": "target_mode", + "type": "target_mode_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_feature_flag_target_flag_key_unique": { + "name": "app_feature_flag_target_flag_key_unique", + "nullsNotDistinct": false, + "columns": [ + "flag_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_feature_flag_user": { + "name": "app_feature_flag_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feature_flag_user_unique_idx": { + "name": "feature_flag_user_unique_idx", + "columns": [ + { + "expression": "flag_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feature_flag_user_key_idx": { + "name": "feature_flag_user_key_idx", + "columns": [ + { + "expression": "flag_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_feature_flag_user_flag_key_app_feature_flag_target_flag_key_fk": { + "name": "app_feature_flag_user_flag_key_app_feature_flag_target_flag_key_fk", + "tableFrom": "app_feature_flag_user", + "tableTo": "app_feature_flag_target", + "columnsFrom": [ + "flag_key" + ], + "columnsTo": [ + "flag_key" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_feature_flag_user_user_id_app_user_id_fk": { + "name": "app_feature_flag_user_user_id_app_user_id_fk", + "tableFrom": "app_feature_flag_user", + "tableTo": "app_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_analytics": { + "name": "app_launch_kit_analytics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_analytics_kit_idx": { + "name": "launch_kit_analytics_kit_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_event_idx": { + "name": "launch_kit_analytics_event_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_created_idx": { + "name": "launch_kit_analytics_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_user_idx": { + "name": "launch_kit_analytics_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_analytics_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_analytics_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_analytics", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_analytics_user_id_app_user_id_fk": { + "name": "app_launch_kit_analytics_user_id_app_user_id_fk", + "tableFrom": "app_launch_kit_analytics", + "tableTo": "app_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_category": { + "name": "app_launch_kit_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_categories_slug_idx": { + "name": "launch_kit_categories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_category_name_unique": { + "name": "app_launch_kit_category_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_launch_kit_category_slug_unique": { + "name": "app_launch_kit_category_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_comment": { + "name": "app_launch_kit_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_comments_kit_created_idx": { + "name": "launch_kit_comments_kit_created_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_comments_user_created_idx": { + "name": "launch_kit_comments_user_created_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_comments_parent_idx": { + "name": "launch_kit_comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_comment_userId_app_user_id_fk": { + "name": "app_launch_kit_comment_userId_app_user_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_comment_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_comment_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_comment_parent_id_app_launch_kit_comment_id_fk": { + "name": "app_launch_kit_comment_parent_id_app_launch_kit_comment_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_launch_kit_comment", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_tag_relation": { + "name": "app_launch_kit_tag_relation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "launch_kit_tag_relations_kit_idx": { + "name": "launch_kit_tag_relations_kit_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tag_relations_tag_idx": { + "name": "launch_kit_tag_relations_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tag_relations_unique": { + "name": "launch_kit_tag_relations_unique", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_tag_relation_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_tag_relation_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_tag_relation", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_tag_relation_tag_id_app_launch_kit_tag_id_fk": { + "name": "app_launch_kit_tag_relation_tag_id_app_launch_kit_tag_id_fk", + "tableFrom": "app_launch_kit_tag_relation", + "tableTo": "app_launch_kit_tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_tag": { + "name": "app_launch_kit_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "category_id": { + "name": "category_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "launch_kit_tags_category_idx": { + "name": "launch_kit_tags_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tags_slug_idx": { + "name": "launch_kit_tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_tag_category_id_app_launch_kit_category_id_fk": { + "name": "app_launch_kit_tag_category_id_app_launch_kit_category_id_fk", + "tableFrom": "app_launch_kit_tag", + "tableTo": "app_launch_kit_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_tag_name_unique": { + "name": "app_launch_kit_tag_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_launch_kit_tag_slug_unique": { + "name": "app_launch_kit_tag_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit": { + "name": "app_launch_kit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "demo_url": { + "name": "demo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clone_count": { + "name": "clone_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kits_active_idx": { + "name": "launch_kits_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kits_slug_idx": { + "name": "launch_kits_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kits_created_idx": { + "name": "launch_kits_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_slug_unique": { + "name": "app_launch_kit_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_module": { + "name": "app_module", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "modules_order_idx": { + "name": "modules_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_entry": { + "name": "app_news_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_entries_author_idx": { + "name": "news_entries_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_type_idx": { + "name": "news_entries_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_published_idx": { + "name": "news_entries_published_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_published_at_idx": { + "name": "news_entries_published_at_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_news_entry_author_id_app_user_id_fk": { + "name": "app_news_entry_author_id_app_user_id_fk", + "tableFrom": "app_news_entry", + "tableTo": "app_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_entry_tag": { + "name": "app_news_entry_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "news_entry_id": { + "name": "news_entry_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "news_tag_id": { + "name": "news_tag_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "news_entry_tags_entry_idx": { + "name": "news_entry_tags_entry_idx", + "columns": [ + { + "expression": "news_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entry_tags_tag_idx": { + "name": "news_entry_tags_tag_idx", + "columns": [ + { + "expression": "news_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entry_tags_unique": { + "name": "news_entry_tags_unique", + "columns": [ + { + "expression": "news_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "news_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_news_entry_tag_news_entry_id_app_news_entry_id_fk": { + "name": "app_news_entry_tag_news_entry_id_app_news_entry_id_fk", + "tableFrom": "app_news_entry_tag", + "tableTo": "app_news_entry", + "columnsFrom": [ + "news_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_news_entry_tag_news_tag_id_app_news_tag_id_fk": { + "name": "app_news_entry_tag_news_tag_id_app_news_tag_id_fk", + "tableFrom": "app_news_entry_tag", + "tableTo": "app_news_tag", + "columnsFrom": [ + "news_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_tag": { + "name": "app_news_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_tags_slug_idx": { + "name": "news_tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_tags_name_idx": { + "name": "news_tags_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_news_tag_name_unique": { + "name": "app_news_tag_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_news_tag_slug_unique": { + "name": "app_news_tag_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_newsletter_signup": { + "name": "app_newsletter_signup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'early_access'" + }, + "isVerified": { + "name": "isVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isUnsubscribed": { + "name": "isUnsubscribed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subscriptionType": { + "name": "subscriptionType", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'newsletter'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "newsletter_signups_email_idx": { + "name": "newsletter_signups_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_source_idx": { + "name": "newsletter_signups_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_type_idx": { + "name": "newsletter_signups_type_idx", + "columns": [ + { + "expression": "subscriptionType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_created_idx": { + "name": "newsletter_signups_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_newsletter_signup_email_unique": { + "name": "app_newsletter_signup_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_profile": { + "name": "app_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "realName": { + "name": "realName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "useDisplayName": { + "name": "useDisplayName", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "imageId": { + "name": "imageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "twitterHandle": { + "name": "twitterHandle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubHandle": { + "name": "githubHandle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websiteUrl": { + "name": "websiteUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublicProfile": { + "name": "isPublicProfile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flair": { + "name": "flair", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "app_profile_userId_app_user_id_fk": { + "name": "app_profile_userId_app_user_id_fk", + "tableFrom": "app_profile", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_profile_userId_unique": { + "name": "app_profile_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_progress": { + "name": "app_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "progress_user_segment_unique_idx": { + "name": "progress_user_segment_unique_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_progress_userId_app_user_id_fk": { + "name": "app_progress_userId_app_user_id_fk", + "tableFrom": "app_progress", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_progress_segmentId_app_segment_id_fk": { + "name": "app_progress_segmentId_app_segment_id_fk", + "tableFrom": "app_progress", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_project": { + "name": "app_project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repositoryUrl": { + "name": "repositoryUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technologies": { + "name": "technologies", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isVisible": { + "name": "isVisible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_user_order_idx": { + "name": "projects_user_order_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_user_visible_idx": { + "name": "projects_user_visible_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isVisible", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_project_userId_app_user_id_fk": { + "name": "app_project_userId_app_user_id_fk", + "tableFrom": "app_project", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_segment": { + "name": "app_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcripts": { + "name": "transcripts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "length": { + "name": "length", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPremium": { + "name": "isPremium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isComingSoon": { + "name": "isComingSoon", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moduleId": { + "name": "moduleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "videoKey": { + "name": "videoKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnailKey": { + "name": "thumbnailKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "segments_slug_idx": { + "name": "segments_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "segments_module_order_idx": { + "name": "segments_module_order_idx", + "columns": [ + { + "expression": "moduleId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_segment_moduleId_app_module_id_fk": { + "name": "app_segment_moduleId_app_module_id_fk", + "tableFrom": "app_segment", + "tableTo": "app_module", + "columnsFrom": [ + "moduleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_session": { + "name": "app_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_session_userId_app_user_id_fk": { + "name": "app_session_userId_app_user_id_fk", + "tableFrom": "app_session", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_testimonial": { + "name": "app_testimonial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emojis": { + "name": "emojis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissionGranted": { + "name": "permissionGranted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "testimonials_created_idx": { + "name": "testimonials_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_testimonial_userId_app_user_id_fk": { + "name": "app_testimonial_userId_app_user_id_fk", + "tableFrom": "app_testimonial", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_unsubscribe_token": { + "name": "app_unsubscribe_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "emailAddress": { + "name": "emailAddress", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isUsed": { + "name": "isUsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unsubscribe_tokens_token_idx": { + "name": "unsubscribe_tokens_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unsubscribe_tokens_user_idx": { + "name": "unsubscribe_tokens_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unsubscribe_tokens_expires_idx": { + "name": "unsubscribe_tokens_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_unsubscribe_token_userId_app_user_id_fk": { + "name": "app_unsubscribe_token_userId_app_user_id_fk", + "tableFrom": "app_unsubscribe_token", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_unsubscribe_token_token_unique": { + "name": "app_unsubscribe_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user_email_preference": { + "name": "app_user_email_preference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "allowCourseUpdates": { + "name": "allowCourseUpdates", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allowPromotional": { + "name": "allowPromotional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_preferences_user_idx": { + "name": "email_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_user_email_preference_userId_app_user_id_fk": { + "name": "app_user_email_preference_userId_app_user_id_fk", + "tableFrom": "app_user_email_preference", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_user_email_preference_userId_unique": { + "name": "app_user_email_preference_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user": { + "name": "app_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "isPremium": { + "name": "isPremium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isAdmin": { + "name": "isAdmin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isEarlyAccess": { + "name": "isEarlyAccess", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_user_email_unique": { + "name": "app_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_video_processing_job": { + "name": "app_video_processing_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "jobType": { + "name": "jobType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "video_processing_jobs_segment_idx": { + "name": "video_processing_jobs_segment_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "video_processing_jobs_status_idx": { + "name": "video_processing_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "video_processing_jobs_created_idx": { + "name": "video_processing_jobs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_video_processing_job_segmentId_app_segment_id_fk": { + "name": "app_video_processing_job_segmentId_app_segment_id_fk", + "tableFrom": "app_video_processing_job", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.affiliate_payout_status_enum": { + "name": "affiliate_payout_status_enum", + "schema": "public", + "values": [ + "pending", + "completed", + "failed" + ] + }, + "public.target_mode_enum": { + "name": "target_mode_enum", + "schema": "public", + "values": [ + "all", + "premium", + "non_premium", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0051_snapshot.json b/drizzle/meta/0051_snapshot.json new file mode 100644 index 00000000..5e78be8f --- /dev/null +++ b/drizzle/meta/0051_snapshot.json @@ -0,0 +1,4513 @@ +{ + "id": "99b49dd6-923d-40eb-a8f7-d77b15cf2dff", + "prevId": "f11ff12d-4fa3-411f-b174-9469d200fbfb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_accounts": { + "name": "app_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_id_google_id_idx": { + "name": "user_id_google_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "googleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_accounts_userId_app_user_id_fk": { + "name": "app_accounts_userId_app_user_id_fk", + "tableFrom": "app_accounts", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_accounts_googleId_unique": { + "name": "app_accounts_googleId_unique", + "nullsNotDistinct": false, + "columns": [ + "googleId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate_payout": { + "name": "app_affiliate_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "affiliateId": { + "name": "affiliateId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paymentMethod": { + "name": "paymentMethod", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transactionId": { + "name": "transactionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeTransferId": { + "name": "stripeTransferId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "affiliate_payout_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paidBy": { + "name": "paidBy", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payouts_affiliate_paid_idx": { + "name": "payouts_affiliate_paid_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "paid_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_stripe_transfer_idx": { + "name": "payouts_stripe_transfer_idx", + "columns": [ + { + "expression": "stripeTransferId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_status_idx": { + "name": "payouts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_payout_affiliateId_app_affiliate_id_fk": { + "name": "app_affiliate_payout_affiliateId_app_affiliate_id_fk", + "tableFrom": "app_affiliate_payout", + "tableTo": "app_affiliate", + "columnsFrom": [ + "affiliateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_affiliate_payout_paidBy_app_user_id_fk": { + "name": "app_affiliate_payout_paidBy_app_user_id_fk", + "tableFrom": "app_affiliate_payout", + "tableTo": "app_user", + "columnsFrom": [ + "paidBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate_referral": { + "name": "app_affiliate_referral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "affiliateId": { + "name": "affiliateId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "purchaserId": { + "name": "purchaserId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "stripeSessionId": { + "name": "stripeSessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commissionRate": { + "name": "commissionRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "discountRate": { + "name": "discountRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "originalCommissionRate": { + "name": "originalCommissionRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "isPaid": { + "name": "isPaid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referrals_affiliate_created_idx": { + "name": "referrals_affiliate_created_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_affiliate_unpaid_idx": { + "name": "referrals_affiliate_unpaid_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isPaid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_purchaser_idx": { + "name": "referrals_purchaser_idx", + "columns": [ + { + "expression": "purchaserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_stripe_session_unique": { + "name": "referrals_stripe_session_unique", + "columns": [ + { + "expression": "stripeSessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_referral_affiliateId_app_affiliate_id_fk": { + "name": "app_affiliate_referral_affiliateId_app_affiliate_id_fk", + "tableFrom": "app_affiliate_referral", + "tableTo": "app_affiliate", + "columnsFrom": [ + "affiliateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_affiliate_referral_purchaserId_app_user_id_fk": { + "name": "app_affiliate_referral_purchaserId_app_user_id_fk", + "tableFrom": "app_affiliate_referral", + "tableTo": "app_user", + "columnsFrom": [ + "purchaserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate": { + "name": "app_affiliate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "affiliateCode": { + "name": "affiliateCode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paymentMethod": { + "name": "paymentMethod", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'link'" + }, + "paymentLink": { + "name": "paymentLink", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commissionRate": { + "name": "commissionRate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "discountRate": { + "name": "discountRate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "totalEarnings": { + "name": "totalEarnings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paidAmount": { + "name": "paidAmount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unpaidBalance": { + "name": "unpaidBalance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "stripeConnectAccountId": { + "name": "stripeConnectAccountId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeAccountStatus": { + "name": "stripeAccountStatus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "stripeChargesEnabled": { + "name": "stripeChargesEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripePayoutsEnabled": { + "name": "stripePayoutsEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeDetailsSubmitted": { + "name": "stripeDetailsSubmitted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeAccountType": { + "name": "stripeAccountType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastStripeSync": { + "name": "lastStripeSync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPayoutError": { + "name": "lastPayoutError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastPayoutErrorAt": { + "name": "lastPayoutErrorAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPayoutAttemptAt": { + "name": "lastPayoutAttemptAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastConnectAttemptAt": { + "name": "lastConnectAttemptAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connectAttemptCount": { + "name": "connectAttemptCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payoutRetryCount": { + "name": "payoutRetryCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "nextPayoutRetryAt": { + "name": "nextPayoutRetryAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "affiliates_user_id_idx": { + "name": "affiliates_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "affiliates_stripe_account_idx": { + "name": "affiliates_stripe_account_idx", + "columns": [ + { + "expression": "stripeConnectAccountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_userId_app_user_id_fk": { + "name": "app_affiliate_userId_app_user_id_fk", + "tableFrom": "app_affiliate", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_affiliate_userId_unique": { + "name": "app_affiliate_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "app_affiliate_affiliateCode_unique": { + "name": "app_affiliate_affiliateCode_unique", + "nullsNotDistinct": false, + "columns": [ + "affiliateCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_agent": { + "name": "app_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_author_idx": { + "name": "agents_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_type_idx": { + "name": "agents_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_public_idx": { + "name": "agents_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_slug_idx": { + "name": "agents_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_agent_author_id_app_user_id_fk": { + "name": "app_agent_author_id_app_user_id_fk", + "tableFrom": "app_agent", + "tableTo": "app_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_agent_name_unique": { + "name": "app_agent_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_agent_slug_unique": { + "name": "app_agent_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_analytics_event": { + "name": "app_analytics_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pagePath": { + "name": "pagePath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ipAddressHash": { + "name": "ipAddressHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analytics_events_session_idx": { + "name": "analytics_events_session_idx", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_type_idx": { + "name": "analytics_events_type_idx", + "columns": [ + { + "expression": "eventType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_created_idx": { + "name": "analytics_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_analytics_session": { + "name": "app_analytics_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrerSource": { + "name": "referrerSource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageViews": { + "name": "pageViews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hasPurchaseIntent": { + "name": "hasPurchaseIntent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasConversion": { + "name": "hasConversion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "analytics_sessions_first_seen_idx": { + "name": "analytics_sessions_first_seen_idx", + "columns": [ + { + "expression": "first_seen", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_sessions_gclid_idx": { + "name": "analytics_sessions_gclid_idx", + "columns": [ + { + "expression": "gclid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_app_setting": { + "name": "app_app_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_settings_key_idx": { + "name": "app_settings_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_attachment": { + "name": "app_attachment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_segment_created_idx": { + "name": "attachments_segment_created_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_attachment_segmentId_app_segment_id_fk": { + "name": "app_attachment_segmentId_app_segment_id_fk", + "tableFrom": "app_attachment", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_blog_post_view": { + "name": "app_blog_post_view", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "blogPostId": { + "name": "blogPostId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ipAddressHash": { + "name": "ipAddressHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blog_post_views_post_idx": { + "name": "blog_post_views_post_idx", + "columns": [ + { + "expression": "blogPostId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_session_idx": { + "name": "blog_post_views_session_idx", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_created_idx": { + "name": "blog_post_views_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_session_post_unique": { + "name": "blog_post_views_session_post_unique", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blogPostId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_blog_post_view_blogPostId_app_blog_post_id_fk": { + "name": "app_blog_post_view_blogPostId_app_blog_post_id_fk", + "tableFrom": "app_blog_post_view", + "tableTo": "app_blog_post", + "columnsFrom": [ + "blogPostId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_blog_post": { + "name": "app_blog_post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublished": { + "name": "isPublished", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "authorId": { + "name": "authorId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "featuredImage": { + "name": "featuredImage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_posts_slug_idx": { + "name": "blog_posts_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_author_idx": { + "name": "blog_posts_author_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_published_idx": { + "name": "blog_posts_published_idx", + "columns": [ + { + "expression": "isPublished", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_created_idx": { + "name": "blog_posts_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_blog_post_authorId_app_user_id_fk": { + "name": "app_blog_post_authorId_app_user_id_fk", + "tableFrom": "app_blog_post", + "tableTo": "app_user", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_blog_post_slug_unique": { + "name": "app_blog_post_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_comment": { + "name": "app_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "repliedToId": { + "name": "repliedToId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_segment_created_idx": { + "name": "comments_segment_created_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_user_created_idx": { + "name": "comments_user_created_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_idx": { + "name": "comments_parent_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_replied_to_idx": { + "name": "comments_replied_to_idx", + "columns": [ + { + "expression": "repliedToId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_comment_userId_app_user_id_fk": { + "name": "app_comment_userId_app_user_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_segmentId_app_segment_id_fk": { + "name": "app_comment_segmentId_app_segment_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_parentId_app_comment_id_fk": { + "name": "app_comment_parentId_app_comment_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_comment", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_repliedToId_app_user_id_fk": { + "name": "app_comment_repliedToId_app_user_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_user", + "columnsFrom": [ + "repliedToId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_email_batch": { + "name": "app_email_batch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipientCount": { + "name": "recipientCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sentCount": { + "name": "sentCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failedCount": { + "name": "failedCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "adminId": { + "name": "adminId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_batches_admin_created_idx": { + "name": "email_batches_admin_created_idx", + "columns": [ + { + "expression": "adminId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_batches_status_idx": { + "name": "email_batches_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_email_batch_adminId_app_user_id_fk": { + "name": "app_email_batch_adminId_app_user_id_fk", + "tableFrom": "app_email_batch", + "tableTo": "app_user", + "columnsFrom": [ + "adminId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_email_template": { + "name": "app_email_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updatedBy": { + "name": "updatedBy", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_templates_key_idx": { + "name": "email_templates_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_templates_active_idx": { + "name": "email_templates_active_idx", + "columns": [ + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_email_template_updatedBy_app_user_id_fk": { + "name": "app_email_template_updatedBy_app_user_id_fk", + "tableFrom": "app_email_template", + "tableTo": "app_user", + "columnsFrom": [ + "updatedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_email_template_key_unique": { + "name": "app_email_template_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_feature_flag_target": { + "name": "app_feature_flag_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_mode": { + "name": "target_mode", + "type": "target_mode_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_feature_flag_target_flag_key_unique": { + "name": "app_feature_flag_target_flag_key_unique", + "nullsNotDistinct": false, + "columns": [ + "flag_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_feature_flag_user": { + "name": "app_feature_flag_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feature_flag_user_unique_idx": { + "name": "feature_flag_user_unique_idx", + "columns": [ + { + "expression": "flag_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feature_flag_user_key_idx": { + "name": "feature_flag_user_key_idx", + "columns": [ + { + "expression": "flag_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_feature_flag_user_flag_key_app_feature_flag_target_flag_key_fk": { + "name": "app_feature_flag_user_flag_key_app_feature_flag_target_flag_key_fk", + "tableFrom": "app_feature_flag_user", + "tableTo": "app_feature_flag_target", + "columnsFrom": [ + "flag_key" + ], + "columnsTo": [ + "flag_key" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_feature_flag_user_user_id_app_user_id_fk": { + "name": "app_feature_flag_user_user_id_app_user_id_fk", + "tableFrom": "app_feature_flag_user", + "tableTo": "app_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_analytics": { + "name": "app_launch_kit_analytics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_analytics_kit_idx": { + "name": "launch_kit_analytics_kit_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_event_idx": { + "name": "launch_kit_analytics_event_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_created_idx": { + "name": "launch_kit_analytics_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_user_idx": { + "name": "launch_kit_analytics_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_analytics_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_analytics_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_analytics", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_analytics_user_id_app_user_id_fk": { + "name": "app_launch_kit_analytics_user_id_app_user_id_fk", + "tableFrom": "app_launch_kit_analytics", + "tableTo": "app_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_category": { + "name": "app_launch_kit_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_categories_slug_idx": { + "name": "launch_kit_categories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_category_name_unique": { + "name": "app_launch_kit_category_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_launch_kit_category_slug_unique": { + "name": "app_launch_kit_category_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_comment": { + "name": "app_launch_kit_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_comments_kit_created_idx": { + "name": "launch_kit_comments_kit_created_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_comments_user_created_idx": { + "name": "launch_kit_comments_user_created_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_comments_parent_idx": { + "name": "launch_kit_comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_comment_userId_app_user_id_fk": { + "name": "app_launch_kit_comment_userId_app_user_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_comment_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_comment_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_comment_parent_id_app_launch_kit_comment_id_fk": { + "name": "app_launch_kit_comment_parent_id_app_launch_kit_comment_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_launch_kit_comment", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_tag_relation": { + "name": "app_launch_kit_tag_relation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "launch_kit_tag_relations_kit_idx": { + "name": "launch_kit_tag_relations_kit_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tag_relations_tag_idx": { + "name": "launch_kit_tag_relations_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tag_relations_unique": { + "name": "launch_kit_tag_relations_unique", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_tag_relation_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_tag_relation_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_tag_relation", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_tag_relation_tag_id_app_launch_kit_tag_id_fk": { + "name": "app_launch_kit_tag_relation_tag_id_app_launch_kit_tag_id_fk", + "tableFrom": "app_launch_kit_tag_relation", + "tableTo": "app_launch_kit_tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_tag": { + "name": "app_launch_kit_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "category_id": { + "name": "category_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "launch_kit_tags_category_idx": { + "name": "launch_kit_tags_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tags_slug_idx": { + "name": "launch_kit_tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_tag_category_id_app_launch_kit_category_id_fk": { + "name": "app_launch_kit_tag_category_id_app_launch_kit_category_id_fk", + "tableFrom": "app_launch_kit_tag", + "tableTo": "app_launch_kit_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_tag_name_unique": { + "name": "app_launch_kit_tag_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_launch_kit_tag_slug_unique": { + "name": "app_launch_kit_tag_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit": { + "name": "app_launch_kit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "demo_url": { + "name": "demo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clone_count": { + "name": "clone_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kits_active_idx": { + "name": "launch_kits_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kits_slug_idx": { + "name": "launch_kits_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kits_created_idx": { + "name": "launch_kits_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_slug_unique": { + "name": "app_launch_kit_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_module": { + "name": "app_module", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "modules_order_idx": { + "name": "modules_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_entry": { + "name": "app_news_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_entries_author_idx": { + "name": "news_entries_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_type_idx": { + "name": "news_entries_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_published_idx": { + "name": "news_entries_published_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_published_at_idx": { + "name": "news_entries_published_at_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_news_entry_author_id_app_user_id_fk": { + "name": "app_news_entry_author_id_app_user_id_fk", + "tableFrom": "app_news_entry", + "tableTo": "app_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_entry_tag": { + "name": "app_news_entry_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "news_entry_id": { + "name": "news_entry_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "news_tag_id": { + "name": "news_tag_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "news_entry_tags_entry_idx": { + "name": "news_entry_tags_entry_idx", + "columns": [ + { + "expression": "news_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entry_tags_tag_idx": { + "name": "news_entry_tags_tag_idx", + "columns": [ + { + "expression": "news_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entry_tags_unique": { + "name": "news_entry_tags_unique", + "columns": [ + { + "expression": "news_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "news_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_news_entry_tag_news_entry_id_app_news_entry_id_fk": { + "name": "app_news_entry_tag_news_entry_id_app_news_entry_id_fk", + "tableFrom": "app_news_entry_tag", + "tableTo": "app_news_entry", + "columnsFrom": [ + "news_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_news_entry_tag_news_tag_id_app_news_tag_id_fk": { + "name": "app_news_entry_tag_news_tag_id_app_news_tag_id_fk", + "tableFrom": "app_news_entry_tag", + "tableTo": "app_news_tag", + "columnsFrom": [ + "news_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_tag": { + "name": "app_news_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_tags_slug_idx": { + "name": "news_tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_tags_name_idx": { + "name": "news_tags_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_news_tag_name_unique": { + "name": "app_news_tag_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_news_tag_slug_unique": { + "name": "app_news_tag_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_newsletter_signup": { + "name": "app_newsletter_signup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'early_access'" + }, + "isVerified": { + "name": "isVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isUnsubscribed": { + "name": "isUnsubscribed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subscriptionType": { + "name": "subscriptionType", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'newsletter'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "newsletter_signups_email_idx": { + "name": "newsletter_signups_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_source_idx": { + "name": "newsletter_signups_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_type_idx": { + "name": "newsletter_signups_type_idx", + "columns": [ + { + "expression": "subscriptionType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_created_idx": { + "name": "newsletter_signups_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_newsletter_signup_email_unique": { + "name": "app_newsletter_signup_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_profile": { + "name": "app_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "realName": { + "name": "realName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "useDisplayName": { + "name": "useDisplayName", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "imageId": { + "name": "imageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "twitterHandle": { + "name": "twitterHandle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubHandle": { + "name": "githubHandle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websiteUrl": { + "name": "websiteUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublicProfile": { + "name": "isPublicProfile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flair": { + "name": "flair", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "app_profile_userId_app_user_id_fk": { + "name": "app_profile_userId_app_user_id_fk", + "tableFrom": "app_profile", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_profile_userId_unique": { + "name": "app_profile_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_progress": { + "name": "app_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "progress_user_segment_unique_idx": { + "name": "progress_user_segment_unique_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_progress_userId_app_user_id_fk": { + "name": "app_progress_userId_app_user_id_fk", + "tableFrom": "app_progress", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_progress_segmentId_app_segment_id_fk": { + "name": "app_progress_segmentId_app_segment_id_fk", + "tableFrom": "app_progress", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_project": { + "name": "app_project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repositoryUrl": { + "name": "repositoryUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technologies": { + "name": "technologies", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isVisible": { + "name": "isVisible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_user_order_idx": { + "name": "projects_user_order_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_user_visible_idx": { + "name": "projects_user_visible_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isVisible", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_project_userId_app_user_id_fk": { + "name": "app_project_userId_app_user_id_fk", + "tableFrom": "app_project", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_segment": { + "name": "app_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcripts": { + "name": "transcripts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "length": { + "name": "length", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPremium": { + "name": "isPremium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isComingSoon": { + "name": "isComingSoon", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moduleId": { + "name": "moduleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "videoKey": { + "name": "videoKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnailKey": { + "name": "thumbnailKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "segments_slug_idx": { + "name": "segments_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "segments_module_order_idx": { + "name": "segments_module_order_idx", + "columns": [ + { + "expression": "moduleId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_segment_moduleId_app_module_id_fk": { + "name": "app_segment_moduleId_app_module_id_fk", + "tableFrom": "app_segment", + "tableTo": "app_module", + "columnsFrom": [ + "moduleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_session": { + "name": "app_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_session_userId_app_user_id_fk": { + "name": "app_session_userId_app_user_id_fk", + "tableFrom": "app_session", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_testimonial": { + "name": "app_testimonial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emojis": { + "name": "emojis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissionGranted": { + "name": "permissionGranted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "testimonials_created_idx": { + "name": "testimonials_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_testimonial_userId_app_user_id_fk": { + "name": "app_testimonial_userId_app_user_id_fk", + "tableFrom": "app_testimonial", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_unsubscribe_token": { + "name": "app_unsubscribe_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "emailAddress": { + "name": "emailAddress", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isUsed": { + "name": "isUsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unsubscribe_tokens_token_idx": { + "name": "unsubscribe_tokens_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unsubscribe_tokens_user_idx": { + "name": "unsubscribe_tokens_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unsubscribe_tokens_expires_idx": { + "name": "unsubscribe_tokens_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_unsubscribe_token_userId_app_user_id_fk": { + "name": "app_unsubscribe_token_userId_app_user_id_fk", + "tableFrom": "app_unsubscribe_token", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_unsubscribe_token_token_unique": { + "name": "app_unsubscribe_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user_email_preference": { + "name": "app_user_email_preference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "allowCourseUpdates": { + "name": "allowCourseUpdates", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allowPromotional": { + "name": "allowPromotional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_preferences_user_idx": { + "name": "email_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_user_email_preference_userId_app_user_id_fk": { + "name": "app_user_email_preference_userId_app_user_id_fk", + "tableFrom": "app_user_email_preference", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_user_email_preference_userId_unique": { + "name": "app_user_email_preference_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user": { + "name": "app_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "isPremium": { + "name": "isPremium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isAdmin": { + "name": "isAdmin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isEarlyAccess": { + "name": "isEarlyAccess", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_user_email_unique": { + "name": "app_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_video_processing_job": { + "name": "app_video_processing_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "jobType": { + "name": "jobType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "video_processing_jobs_segment_idx": { + "name": "video_processing_jobs_segment_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "video_processing_jobs_status_idx": { + "name": "video_processing_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "video_processing_jobs_created_idx": { + "name": "video_processing_jobs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_video_processing_job_segmentId_app_segment_id_fk": { + "name": "app_video_processing_job_segmentId_app_segment_id_fk", + "tableFrom": "app_video_processing_job", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.affiliate_payout_status_enum": { + "name": "affiliate_payout_status_enum", + "schema": "public", + "values": [ + "pending", + "completed", + "failed" + ] + }, + "public.target_mode_enum": { + "name": "target_mode_enum", + "schema": "public", + "values": [ + "all", + "premium", + "non_premium", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0052_snapshot.json b/drizzle/meta/0052_snapshot.json new file mode 100644 index 00000000..12678cc0 --- /dev/null +++ b/drizzle/meta/0052_snapshot.json @@ -0,0 +1,4538 @@ +{ + "id": "25369a9c-d3f7-455f-8df5-ce2f6ff5b753", + "prevId": "99b49dd6-923d-40eb-a8f7-d77b15cf2dff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_accounts": { + "name": "app_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_id_google_id_idx": { + "name": "user_id_google_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "googleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_accounts_userId_app_user_id_fk": { + "name": "app_accounts_userId_app_user_id_fk", + "tableFrom": "app_accounts", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_accounts_googleId_unique": { + "name": "app_accounts_googleId_unique", + "nullsNotDistinct": false, + "columns": [ + "googleId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate_payout": { + "name": "app_affiliate_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "affiliateId": { + "name": "affiliateId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paymentMethod": { + "name": "paymentMethod", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transactionId": { + "name": "transactionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeTransferId": { + "name": "stripeTransferId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "affiliate_payout_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paidBy": { + "name": "paidBy", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payouts_affiliate_paid_idx": { + "name": "payouts_affiliate_paid_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "paid_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_stripe_transfer_idx": { + "name": "payouts_stripe_transfer_idx", + "columns": [ + { + "expression": "stripeTransferId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_status_idx": { + "name": "payouts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_payout_affiliateId_app_affiliate_id_fk": { + "name": "app_affiliate_payout_affiliateId_app_affiliate_id_fk", + "tableFrom": "app_affiliate_payout", + "tableTo": "app_affiliate", + "columnsFrom": [ + "affiliateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_affiliate_payout_paidBy_app_user_id_fk": { + "name": "app_affiliate_payout_paidBy_app_user_id_fk", + "tableFrom": "app_affiliate_payout", + "tableTo": "app_user", + "columnsFrom": [ + "paidBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate_referral": { + "name": "app_affiliate_referral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "affiliateId": { + "name": "affiliateId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "purchaserId": { + "name": "purchaserId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "stripeSessionId": { + "name": "stripeSessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commissionRate": { + "name": "commissionRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "discountRate": { + "name": "discountRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "originalCommissionRate": { + "name": "originalCommissionRate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "isPaid": { + "name": "isPaid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referrals_affiliate_created_idx": { + "name": "referrals_affiliate_created_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_affiliate_unpaid_idx": { + "name": "referrals_affiliate_unpaid_idx", + "columns": [ + { + "expression": "affiliateId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isPaid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_purchaser_idx": { + "name": "referrals_purchaser_idx", + "columns": [ + { + "expression": "purchaserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referrals_stripe_session_unique": { + "name": "referrals_stripe_session_unique", + "columns": [ + { + "expression": "stripeSessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_referral_affiliateId_app_affiliate_id_fk": { + "name": "app_affiliate_referral_affiliateId_app_affiliate_id_fk", + "tableFrom": "app_affiliate_referral", + "tableTo": "app_affiliate", + "columnsFrom": [ + "affiliateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_affiliate_referral_purchaserId_app_user_id_fk": { + "name": "app_affiliate_referral_purchaserId_app_user_id_fk", + "tableFrom": "app_affiliate_referral", + "tableTo": "app_user", + "columnsFrom": [ + "purchaserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_affiliate": { + "name": "app_affiliate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "affiliateCode": { + "name": "affiliateCode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paymentMethod": { + "name": "paymentMethod", + "type": "affiliate_payment_method_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'link'" + }, + "paymentLink": { + "name": "paymentLink", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commissionRate": { + "name": "commissionRate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "discountRate": { + "name": "discountRate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "totalEarnings": { + "name": "totalEarnings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paidAmount": { + "name": "paidAmount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unpaidBalance": { + "name": "unpaidBalance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "stripeConnectAccountId": { + "name": "stripeConnectAccountId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeAccountStatus": { + "name": "stripeAccountStatus", + "type": "stripe_account_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "stripeChargesEnabled": { + "name": "stripeChargesEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripePayoutsEnabled": { + "name": "stripePayoutsEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeDetailsSubmitted": { + "name": "stripeDetailsSubmitted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeAccountType": { + "name": "stripeAccountType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastStripeSync": { + "name": "lastStripeSync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPayoutError": { + "name": "lastPayoutError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastPayoutErrorAt": { + "name": "lastPayoutErrorAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPayoutAttemptAt": { + "name": "lastPayoutAttemptAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastConnectAttemptAt": { + "name": "lastConnectAttemptAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connectAttemptCount": { + "name": "connectAttemptCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payoutRetryCount": { + "name": "payoutRetryCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "nextPayoutRetryAt": { + "name": "nextPayoutRetryAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "affiliates_user_id_idx": { + "name": "affiliates_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "affiliates_stripe_account_idx": { + "name": "affiliates_stripe_account_idx", + "columns": [ + { + "expression": "stripeConnectAccountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_affiliate_userId_app_user_id_fk": { + "name": "app_affiliate_userId_app_user_id_fk", + "tableFrom": "app_affiliate", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_affiliate_userId_unique": { + "name": "app_affiliate_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "app_affiliate_affiliateCode_unique": { + "name": "app_affiliate_affiliateCode_unique", + "nullsNotDistinct": false, + "columns": [ + "affiliateCode" + ] + } + }, + "policies": {}, + "checkConstraints": { + "discount_rate_check": { + "name": "discount_rate_check", + "value": "\"app_affiliate\".\"discountRate\" <= \"app_affiliate\".\"commissionRate\" AND \"app_affiliate\".\"discountRate\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.app_agent": { + "name": "app_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_author_idx": { + "name": "agents_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_type_idx": { + "name": "agents_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_public_idx": { + "name": "agents_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_slug_idx": { + "name": "agents_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_agent_author_id_app_user_id_fk": { + "name": "app_agent_author_id_app_user_id_fk", + "tableFrom": "app_agent", + "tableTo": "app_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_agent_name_unique": { + "name": "app_agent_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_agent_slug_unique": { + "name": "app_agent_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_analytics_event": { + "name": "app_analytics_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pagePath": { + "name": "pagePath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ipAddressHash": { + "name": "ipAddressHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analytics_events_session_idx": { + "name": "analytics_events_session_idx", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_type_idx": { + "name": "analytics_events_type_idx", + "columns": [ + { + "expression": "eventType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_created_idx": { + "name": "analytics_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_analytics_session": { + "name": "app_analytics_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrerSource": { + "name": "referrerSource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageViews": { + "name": "pageViews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hasPurchaseIntent": { + "name": "hasPurchaseIntent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasConversion": { + "name": "hasConversion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "analytics_sessions_first_seen_idx": { + "name": "analytics_sessions_first_seen_idx", + "columns": [ + { + "expression": "first_seen", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_sessions_gclid_idx": { + "name": "analytics_sessions_gclid_idx", + "columns": [ + { + "expression": "gclid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_app_setting": { + "name": "app_app_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_settings_key_idx": { + "name": "app_settings_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_attachment": { + "name": "app_attachment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_segment_created_idx": { + "name": "attachments_segment_created_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_attachment_segmentId_app_segment_id_fk": { + "name": "app_attachment_segmentId_app_segment_id_fk", + "tableFrom": "app_attachment", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_blog_post_view": { + "name": "app_blog_post_view", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "blogPostId": { + "name": "blogPostId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ipAddressHash": { + "name": "ipAddressHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blog_post_views_post_idx": { + "name": "blog_post_views_post_idx", + "columns": [ + { + "expression": "blogPostId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_session_idx": { + "name": "blog_post_views_session_idx", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_created_idx": { + "name": "blog_post_views_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_post_views_session_post_unique": { + "name": "blog_post_views_session_post_unique", + "columns": [ + { + "expression": "sessionId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blogPostId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_blog_post_view_blogPostId_app_blog_post_id_fk": { + "name": "app_blog_post_view_blogPostId_app_blog_post_id_fk", + "tableFrom": "app_blog_post_view", + "tableTo": "app_blog_post", + "columnsFrom": [ + "blogPostId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_blog_post": { + "name": "app_blog_post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublished": { + "name": "isPublished", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "authorId": { + "name": "authorId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "featuredImage": { + "name": "featuredImage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_posts_slug_idx": { + "name": "blog_posts_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_author_idx": { + "name": "blog_posts_author_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_published_idx": { + "name": "blog_posts_published_idx", + "columns": [ + { + "expression": "isPublished", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_created_idx": { + "name": "blog_posts_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_blog_post_authorId_app_user_id_fk": { + "name": "app_blog_post_authorId_app_user_id_fk", + "tableFrom": "app_blog_post", + "tableTo": "app_user", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_blog_post_slug_unique": { + "name": "app_blog_post_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_comment": { + "name": "app_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "repliedToId": { + "name": "repliedToId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_segment_created_idx": { + "name": "comments_segment_created_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_user_created_idx": { + "name": "comments_user_created_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_idx": { + "name": "comments_parent_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_replied_to_idx": { + "name": "comments_replied_to_idx", + "columns": [ + { + "expression": "repliedToId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_comment_userId_app_user_id_fk": { + "name": "app_comment_userId_app_user_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_segmentId_app_segment_id_fk": { + "name": "app_comment_segmentId_app_segment_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_parentId_app_comment_id_fk": { + "name": "app_comment_parentId_app_comment_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_comment", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comment_repliedToId_app_user_id_fk": { + "name": "app_comment_repliedToId_app_user_id_fk", + "tableFrom": "app_comment", + "tableTo": "app_user", + "columnsFrom": [ + "repliedToId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_email_batch": { + "name": "app_email_batch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipientCount": { + "name": "recipientCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sentCount": { + "name": "sentCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failedCount": { + "name": "failedCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "adminId": { + "name": "adminId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_batches_admin_created_idx": { + "name": "email_batches_admin_created_idx", + "columns": [ + { + "expression": "adminId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_batches_status_idx": { + "name": "email_batches_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_email_batch_adminId_app_user_id_fk": { + "name": "app_email_batch_adminId_app_user_id_fk", + "tableFrom": "app_email_batch", + "tableTo": "app_user", + "columnsFrom": [ + "adminId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_email_template": { + "name": "app_email_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updatedBy": { + "name": "updatedBy", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_templates_key_idx": { + "name": "email_templates_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_templates_active_idx": { + "name": "email_templates_active_idx", + "columns": [ + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_email_template_updatedBy_app_user_id_fk": { + "name": "app_email_template_updatedBy_app_user_id_fk", + "tableFrom": "app_email_template", + "tableTo": "app_user", + "columnsFrom": [ + "updatedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_email_template_key_unique": { + "name": "app_email_template_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_feature_flag_target": { + "name": "app_feature_flag_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_mode": { + "name": "target_mode", + "type": "target_mode_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_feature_flag_target_flag_key_unique": { + "name": "app_feature_flag_target_flag_key_unique", + "nullsNotDistinct": false, + "columns": [ + "flag_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_feature_flag_user": { + "name": "app_feature_flag_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feature_flag_user_unique_idx": { + "name": "feature_flag_user_unique_idx", + "columns": [ + { + "expression": "flag_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feature_flag_user_key_idx": { + "name": "feature_flag_user_key_idx", + "columns": [ + { + "expression": "flag_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_feature_flag_user_flag_key_app_feature_flag_target_flag_key_fk": { + "name": "app_feature_flag_user_flag_key_app_feature_flag_target_flag_key_fk", + "tableFrom": "app_feature_flag_user", + "tableTo": "app_feature_flag_target", + "columnsFrom": [ + "flag_key" + ], + "columnsTo": [ + "flag_key" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_feature_flag_user_user_id_app_user_id_fk": { + "name": "app_feature_flag_user_user_id_app_user_id_fk", + "tableFrom": "app_feature_flag_user", + "tableTo": "app_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_analytics": { + "name": "app_launch_kit_analytics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_analytics_kit_idx": { + "name": "launch_kit_analytics_kit_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_event_idx": { + "name": "launch_kit_analytics_event_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_created_idx": { + "name": "launch_kit_analytics_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_analytics_user_idx": { + "name": "launch_kit_analytics_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_analytics_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_analytics_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_analytics", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_analytics_user_id_app_user_id_fk": { + "name": "app_launch_kit_analytics_user_id_app_user_id_fk", + "tableFrom": "app_launch_kit_analytics", + "tableTo": "app_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_category": { + "name": "app_launch_kit_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_categories_slug_idx": { + "name": "launch_kit_categories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_category_name_unique": { + "name": "app_launch_kit_category_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_launch_kit_category_slug_unique": { + "name": "app_launch_kit_category_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_comment": { + "name": "app_launch_kit_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kit_comments_kit_created_idx": { + "name": "launch_kit_comments_kit_created_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_comments_user_created_idx": { + "name": "launch_kit_comments_user_created_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_comments_parent_idx": { + "name": "launch_kit_comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_comment_userId_app_user_id_fk": { + "name": "app_launch_kit_comment_userId_app_user_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_comment_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_comment_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_comment_parent_id_app_launch_kit_comment_id_fk": { + "name": "app_launch_kit_comment_parent_id_app_launch_kit_comment_id_fk", + "tableFrom": "app_launch_kit_comment", + "tableTo": "app_launch_kit_comment", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_tag_relation": { + "name": "app_launch_kit_tag_relation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "launch_kit_id": { + "name": "launch_kit_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "launch_kit_tag_relations_kit_idx": { + "name": "launch_kit_tag_relations_kit_idx", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tag_relations_tag_idx": { + "name": "launch_kit_tag_relations_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tag_relations_unique": { + "name": "launch_kit_tag_relations_unique", + "columns": [ + { + "expression": "launch_kit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_tag_relation_launch_kit_id_app_launch_kit_id_fk": { + "name": "app_launch_kit_tag_relation_launch_kit_id_app_launch_kit_id_fk", + "tableFrom": "app_launch_kit_tag_relation", + "tableTo": "app_launch_kit", + "columnsFrom": [ + "launch_kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_launch_kit_tag_relation_tag_id_app_launch_kit_tag_id_fk": { + "name": "app_launch_kit_tag_relation_tag_id_app_launch_kit_tag_id_fk", + "tableFrom": "app_launch_kit_tag_relation", + "tableTo": "app_launch_kit_tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit_tag": { + "name": "app_launch_kit_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "category_id": { + "name": "category_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "launch_kit_tags_category_idx": { + "name": "launch_kit_tags_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kit_tags_slug_idx": { + "name": "launch_kit_tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_launch_kit_tag_category_id_app_launch_kit_category_id_fk": { + "name": "app_launch_kit_tag_category_id_app_launch_kit_category_id_fk", + "tableFrom": "app_launch_kit_tag", + "tableTo": "app_launch_kit_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_tag_name_unique": { + "name": "app_launch_kit_tag_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_launch_kit_tag_slug_unique": { + "name": "app_launch_kit_tag_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_launch_kit": { + "name": "app_launch_kit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "demo_url": { + "name": "demo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clone_count": { + "name": "clone_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "launch_kits_active_idx": { + "name": "launch_kits_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kits_slug_idx": { + "name": "launch_kits_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_kits_created_idx": { + "name": "launch_kits_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_launch_kit_slug_unique": { + "name": "app_launch_kit_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_module": { + "name": "app_module", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "modules_order_idx": { + "name": "modules_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_entry": { + "name": "app_news_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_entries_author_idx": { + "name": "news_entries_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_type_idx": { + "name": "news_entries_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_published_idx": { + "name": "news_entries_published_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entries_published_at_idx": { + "name": "news_entries_published_at_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_news_entry_author_id_app_user_id_fk": { + "name": "app_news_entry_author_id_app_user_id_fk", + "tableFrom": "app_news_entry", + "tableTo": "app_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_entry_tag": { + "name": "app_news_entry_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "news_entry_id": { + "name": "news_entry_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "news_tag_id": { + "name": "news_tag_id", + "type": "serial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "news_entry_tags_entry_idx": { + "name": "news_entry_tags_entry_idx", + "columns": [ + { + "expression": "news_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entry_tags_tag_idx": { + "name": "news_entry_tags_tag_idx", + "columns": [ + { + "expression": "news_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_entry_tags_unique": { + "name": "news_entry_tags_unique", + "columns": [ + { + "expression": "news_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "news_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_news_entry_tag_news_entry_id_app_news_entry_id_fk": { + "name": "app_news_entry_tag_news_entry_id_app_news_entry_id_fk", + "tableFrom": "app_news_entry_tag", + "tableTo": "app_news_entry", + "columnsFrom": [ + "news_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_news_entry_tag_news_tag_id_app_news_tag_id_fk": { + "name": "app_news_entry_tag_news_tag_id_app_news_tag_id_fk", + "tableFrom": "app_news_entry_tag", + "tableTo": "app_news_tag", + "columnsFrom": [ + "news_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_news_tag": { + "name": "app_news_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_tags_slug_idx": { + "name": "news_tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_tags_name_idx": { + "name": "news_tags_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_news_tag_name_unique": { + "name": "app_news_tag_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "app_news_tag_slug_unique": { + "name": "app_news_tag_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_newsletter_signup": { + "name": "app_newsletter_signup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'early_access'" + }, + "isVerified": { + "name": "isVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isUnsubscribed": { + "name": "isUnsubscribed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subscriptionType": { + "name": "subscriptionType", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'newsletter'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "newsletter_signups_email_idx": { + "name": "newsletter_signups_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_source_idx": { + "name": "newsletter_signups_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_type_idx": { + "name": "newsletter_signups_type_idx", + "columns": [ + { + "expression": "subscriptionType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletter_signups_created_idx": { + "name": "newsletter_signups_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_newsletter_signup_email_unique": { + "name": "app_newsletter_signup_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_profile": { + "name": "app_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "realName": { + "name": "realName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "useDisplayName": { + "name": "useDisplayName", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "imageId": { + "name": "imageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "twitterHandle": { + "name": "twitterHandle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubHandle": { + "name": "githubHandle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websiteUrl": { + "name": "websiteUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublicProfile": { + "name": "isPublicProfile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flair": { + "name": "flair", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "app_profile_userId_app_user_id_fk": { + "name": "app_profile_userId_app_user_id_fk", + "tableFrom": "app_profile", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_profile_userId_unique": { + "name": "app_profile_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_progress": { + "name": "app_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "progress_user_segment_unique_idx": { + "name": "progress_user_segment_unique_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_progress_userId_app_user_id_fk": { + "name": "app_progress_userId_app_user_id_fk", + "tableFrom": "app_progress", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_progress_segmentId_app_segment_id_fk": { + "name": "app_progress_segmentId_app_segment_id_fk", + "tableFrom": "app_progress", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_project": { + "name": "app_project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repositoryUrl": { + "name": "repositoryUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technologies": { + "name": "technologies", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isVisible": { + "name": "isVisible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_user_order_idx": { + "name": "projects_user_order_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_user_visible_idx": { + "name": "projects_user_visible_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isVisible", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_project_userId_app_user_id_fk": { + "name": "app_project_userId_app_user_id_fk", + "tableFrom": "app_project", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_segment": { + "name": "app_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcripts": { + "name": "transcripts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "length": { + "name": "length", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPremium": { + "name": "isPremium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isComingSoon": { + "name": "isComingSoon", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moduleId": { + "name": "moduleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "videoKey": { + "name": "videoKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnailKey": { + "name": "thumbnailKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "segments_slug_idx": { + "name": "segments_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "segments_module_order_idx": { + "name": "segments_module_order_idx", + "columns": [ + { + "expression": "moduleId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_segment_moduleId_app_module_id_fk": { + "name": "app_segment_moduleId_app_module_id_fk", + "tableFrom": "app_segment", + "tableTo": "app_module", + "columnsFrom": [ + "moduleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_session": { + "name": "app_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_session_userId_app_user_id_fk": { + "name": "app_session_userId_app_user_id_fk", + "tableFrom": "app_session", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_testimonial": { + "name": "app_testimonial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emojis": { + "name": "emojis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissionGranted": { + "name": "permissionGranted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "testimonials_created_idx": { + "name": "testimonials_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_testimonial_userId_app_user_id_fk": { + "name": "app_testimonial_userId_app_user_id_fk", + "tableFrom": "app_testimonial", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_unsubscribe_token": { + "name": "app_unsubscribe_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "emailAddress": { + "name": "emailAddress", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isUsed": { + "name": "isUsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unsubscribe_tokens_token_idx": { + "name": "unsubscribe_tokens_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unsubscribe_tokens_user_idx": { + "name": "unsubscribe_tokens_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unsubscribe_tokens_expires_idx": { + "name": "unsubscribe_tokens_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_unsubscribe_token_userId_app_user_id_fk": { + "name": "app_unsubscribe_token_userId_app_user_id_fk", + "tableFrom": "app_unsubscribe_token", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_unsubscribe_token_token_unique": { + "name": "app_unsubscribe_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user_email_preference": { + "name": "app_user_email_preference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "allowCourseUpdates": { + "name": "allowCourseUpdates", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allowPromotional": { + "name": "allowPromotional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_preferences_user_idx": { + "name": "email_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_user_email_preference_userId_app_user_id_fk": { + "name": "app_user_email_preference_userId_app_user_id_fk", + "tableFrom": "app_user_email_preference", + "tableTo": "app_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_user_email_preference_userId_unique": { + "name": "app_user_email_preference_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user": { + "name": "app_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "isPremium": { + "name": "isPremium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isAdmin": { + "name": "isAdmin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isEarlyAccess": { + "name": "isEarlyAccess", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_user_email_unique": { + "name": "app_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_video_processing_job": { + "name": "app_video_processing_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "segmentId": { + "name": "segmentId", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "jobType": { + "name": "jobType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "video_processing_jobs_segment_idx": { + "name": "video_processing_jobs_segment_idx", + "columns": [ + { + "expression": "segmentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "video_processing_jobs_status_idx": { + "name": "video_processing_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "video_processing_jobs_created_idx": { + "name": "video_processing_jobs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_video_processing_job_segmentId_app_segment_id_fk": { + "name": "app_video_processing_job_segmentId_app_segment_id_fk", + "tableFrom": "app_video_processing_job", + "tableTo": "app_segment", + "columnsFrom": [ + "segmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.affiliate_payment_method_enum": { + "name": "affiliate_payment_method_enum", + "schema": "public", + "values": [ + "link", + "stripe" + ] + }, + "public.affiliate_payout_status_enum": { + "name": "affiliate_payout_status_enum", + "schema": "public", + "values": [ + "pending", + "completed", + "failed" + ] + }, + "public.stripe_account_status_enum": { + "name": "stripe_account_status_enum", + "schema": "public", + "values": [ + "not_started", + "onboarding", + "active", + "restricted" + ] + }, + "public.target_mode_enum": { + "name": "target_mode_enum", + "schema": "public", + "values": [ + "all", + "premium", + "non_premium", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ed4c4329..6abd9bfe 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -351,6 +351,27 @@ "when": 1766615388060, "tag": "0049_complete_groot", "breakpoints": true + }, + { + "idx": 50, + "version": "7", + "when": 1766859824038, + "tag": "0050_gorgeous_kulan_gath", + "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1766871016132, + "tag": "0051_chilly_the_watchers", + "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1766872516164, + "tag": "0052_funny_hardball", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index d64743dd..ecc5e97d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,13 @@ "private": true, "sideEffects": false, "type": "module", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "@tailwindcss/oxide", + "@parcel/watcher" + ] + }, "scripts": { "dev": "npm run db:up && vite dev", "build": "vite build", @@ -46,13 +53,16 @@ "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", @@ -67,6 +77,7 @@ "@tanstack/react-router": "^1.143.3", "@tanstack/react-router-with-query": "^1.130.17", "@tanstack/react-start": "^1.143.3", + "@tanstack/react-table": "^8.21.3", "arctic": "^3.7.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -81,9 +92,11 @@ "marked": "^16.2.0", "nitro": "^3.0.1-alpha.1", "nprogress": "^0.2.0", + "nuqs": "^2.8.5", "pg": "^8.16.3", "react": "^19.1.0", "react-confetti": "^6.4.0", + "react-day-picker": "^9.13.0", "react-dom": "^19.1.0", "react-dropzone": "^14.3.8", "react-email": "^4.2.8", @@ -124,6 +137,7 @@ "drizzle-kit": "^0.31.4", "playwright": "^1.54.2", "tailwindcss": "^4.1.11", + "tsx": "^4.21.0", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5c485c..6ff2d4f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.3.2 version: 1.3.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -50,12 +53,18 @@ importers: '@radix-ui/react-progress': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.1.8)(react@19.1.0) @@ -98,6 +107,9 @@ importers: '@tanstack/react-start': specifier: ^1.143.3 version: 1.143.3(crossws@0.4.1(srvx@0.9.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.1.1(@types/node@24.1.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.21.0)) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) arctic: specifier: ^3.7.0 version: 3.7.0 @@ -140,6 +152,9 @@ importers: nprogress: specifier: ^0.2.0 version: 0.2.0 + nuqs: + specifier: ^2.8.5 + version: 2.8.6(@tanstack/react-router@1.143.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) pg: specifier: ^8.16.3 version: 8.16.3 @@ -149,6 +164,9 @@ importers: react-confetti: specifier: ^6.4.0 version: 6.4.0(react@19.1.0) + react-day-picker: + specifier: ^9.13.0 + version: 9.13.0(react@19.1.0) react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) @@ -264,6 +282,9 @@ importers: tailwindcss: specifier: ^4.1.11 version: 4.1.11 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -719,6 +740,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1592,6 +1616,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1824,6 +1861,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -1863,6 +1913,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.10': resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==} peerDependencies: @@ -1876,6 +1939,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -1902,6 +1978,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -2693,6 +2782,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2940,6 +3032,13 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/router-core@1.143.3': resolution: {integrity: sha512-X0OiWyoGkgZoVtmKkkS8r32mMwde4aJdKmkzfsdYvLMdI+F9sOgsam7Te2xu/gkZwRY5guUEduXoX5shRcAPIQ==} engines: {node: '>=12'} @@ -3014,6 +3113,10 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-file-routes@1.141.0': resolution: {integrity: sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A==} engines: {node: '>=12'} @@ -3558,6 +3661,9 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -4711,6 +4817,27 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.8.6: + resolution: {integrity: sha512-aRxeX68b4ULmhio8AADL2be1FWDy0EPqaByPvIYWrA7Pm07UjlrICp/VPlSnXJNAG0+3MQwv3OporO2sOXMVGA==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nypm@0.6.0: resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==} engines: {node: ^14.16.0 || >=16.10.0} @@ -4943,6 +5070,12 @@ packages: peerDependencies: react: ^16.3.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 + react-day-picker@9.13.0: + resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -6926,6 +7059,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@date-fns/tz@1.4.1': {} + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.7.1': @@ -7544,6 +7679,22 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) @@ -7787,6 +7938,16 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) @@ -7815,6 +7976,24 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -7832,6 +8011,23 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 @@ -7870,6 +8066,25 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) @@ -8821,6 +9036,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -9059,6 +9276,12 @@ snapshots: react-dom: 19.1.0(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@tanstack/router-core@1.143.3': dependencies: '@tanstack/history': 1.141.0 @@ -9204,6 +9427,8 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-file-routes@1.141.0': {} '@tybys/wasm-util@0.10.1': @@ -9823,6 +10048,8 @@ snapshots: data-uri-to-buffer@4.0.1: optional: true + date-fns-jalali@4.1.0-0: {} + date-fns@4.1.0: {} db0@0.3.4(drizzle-orm@0.44.3(@types/pg@8.15.4)(pg@8.16.3)): @@ -11109,6 +11336,13 @@ snapshots: dependencies: boolbase: 1.0.0 + nuqs@2.8.6(@tanstack/react-router@1.143.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.1.0 + optionalDependencies: + '@tanstack/react-router': 1.143.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nypm@0.6.0: dependencies: citty: 0.1.6 @@ -11371,6 +11605,13 @@ snapshots: react: 19.1.0 tween-functions: 1.2.0 + react-day-picker@9.13.0(react@19.1.0): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.1.0 + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 diff --git a/src/components/blocks/number-input-with-controls.tsx b/src/components/blocks/number-input-with-controls.tsx new file mode 100644 index 00000000..f0a91333 --- /dev/null +++ b/src/components/blocks/number-input-with-controls.tsx @@ -0,0 +1,130 @@ +"use client"; + +import * as React from "react"; +import { Button } from "~/components/ui/button"; +import { + ButtonGroup, + InputGroup, + InputGroupInput, + InputGroupAddon, +} from "~/components/ui/button-group"; +import { Minus, Plus, Save, Loader2 } from "lucide-react"; + +interface NumberInputWithControlsProps { + /** Current display value */ + value: string; + /** Callback when value changes */ + onChange: (value: string) => void; + /** Callback when save button is clicked */ + onSave: () => void; + /** Label shown before the input */ + label?: string; + /** Prefix shown inside the input (e.g., "$") */ + prefix?: string; + /** Suffix shown inside the input (e.g., "%") */ + suffix?: string; + /** Step for +/- buttons */ + step?: number; + /** Minimum value */ + min?: number; + /** Maximum value */ + max?: number; + /** Whether the input is disabled */ + disabled?: boolean; + /** Whether save is pending */ + isPending?: boolean; + /** Whether there are unsaved changes */ + hasChanges?: boolean; + /** Width class for the input group */ + inputWidth?: string; +} + +export function NumberInputWithControls({ + value, + onChange, + onSave, + label, + prefix, + suffix, + step = 1, + min = 0, + max, + disabled = false, + isPending = false, + hasChanges = false, + inputWidth = "w-32", +}: NumberInputWithControlsProps) { + const handleInputChange = (e: React.ChangeEvent) => { + const val = e.target.value; + if (val === "" || /^\d+$/.test(val)) { + let numVal = val === "" ? 0 : parseInt(val, 10); + if (max !== undefined && numVal > max) numVal = max; + if (numVal < min) numVal = min; + onChange(numVal.toString()); + } + }; + + const handleDecrement = () => { + const current = parseInt(value) || 0; + onChange(Math.max(min, current - step).toString()); + }; + + const handleIncrement = () => { + const current = parseInt(value) || 0; + const newVal = current + step; + onChange(max !== undefined ? Math.min(max, newVal).toString() : newVal.toString()); + }; + + return ( + + + {label && ( + + {label} + + )} + {prefix && {prefix}} + + {suffix && {suffix}} + + + + + + ); +} diff --git a/src/components/data-table/data-table-column-header.tsx b/src/components/data-table/data-table-column-header.tsx new file mode 100644 index 00000000..1186bbd6 --- /dev/null +++ b/src/components/data-table/data-table-column-header.tsx @@ -0,0 +1,103 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { + ChevronDown, + ChevronsUpDown, + ChevronUp, + EyeOff, + X, +} from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { cn } from "~/lib/utils"; + +interface DataTableColumnHeaderProps + extends React.ComponentProps { + column: Column; + label: string; +} + +export function DataTableColumnHeader({ + column, + label, + className, + ...props +}: DataTableColumnHeaderProps) { + const isRightAligned = className?.includes("ml-auto"); + + if (!column.getCanSort() && !column.getCanHide()) { + return
{label}
; + } + + return ( + + + {label} + {column.getCanSort() && + (column.getIsSorted() === "desc" ? ( + + ) : column.getIsSorted() === "asc" ? ( + + ) : ( + + ))} + + + {column.getCanSort() && ( + <> + column.toggleSorting(false)} + > + + Asc + + column.toggleSorting(true)} + > + + Desc + + {column.getIsSorted() && ( + column.clearSorting()} + > + + Reset + + )} + + )} + {column.getCanHide() && ( + column.toggleVisibility(false)} + > + + Hide + + )} + + + ); +} diff --git a/src/components/data-table/data-table-date-filter.tsx b/src/components/data-table/data-table-date-filter.tsx new file mode 100644 index 00000000..63e0a303 --- /dev/null +++ b/src/components/data-table/data-table-date-filter.tsx @@ -0,0 +1,231 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { CalendarIcon, XCircle } from "lucide-react"; +import * as React from "react"; +import type { DateRange } from "react-day-picker"; + +import { Button } from "~/components/ui/button"; +import { Calendar } from "~/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { Separator } from "~/components/ui/separator"; +import { formatDate } from "~/lib/format"; + +type DateSelection = Date[] | DateRange; + +function getIsDateRange(value: DateSelection): value is DateRange { + return value && typeof value === "object" && !Array.isArray(value); +} + +function parseAsDate(timestamp: number | string | undefined): Date | undefined { + if (!timestamp) return undefined; + const numericTimestamp = + typeof timestamp === "string" ? Number(timestamp) : timestamp; + const date = new Date(numericTimestamp); + return !Number.isNaN(date.getTime()) ? date : undefined; +} + +function parseColumnFilterValue(value: unknown) { + if (value === null || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value.map((item) => { + if (typeof item === "number" || typeof item === "string") { + return item; + } + return undefined; + }); + } + + if (typeof value === "string" || typeof value === "number") { + return [value]; + } + + return []; +} + +interface DataTableDateFilterProps { + column: Column; + title?: string; + multiple?: boolean; +} + +export function DataTableDateFilter({ + column, + title, + multiple, +}: DataTableDateFilterProps) { + const columnFilterValue = column.getFilterValue(); + + const selectedDates = React.useMemo(() => { + if (!columnFilterValue) { + return multiple ? { from: undefined, to: undefined } : []; + } + + if (multiple) { + const timestamps = parseColumnFilterValue(columnFilterValue); + return { + from: parseAsDate(timestamps[0]), + to: parseAsDate(timestamps[1]), + }; + } + + const timestamps = parseColumnFilterValue(columnFilterValue); + const date = parseAsDate(timestamps[0]); + return date ? [date] : []; + }, [columnFilterValue, multiple]); + + const onSelect = React.useCallback( + (date: Date | DateRange | undefined) => { + if (!date) { + column.setFilterValue(undefined); + return; + } + + if (multiple && !("getTime" in date)) { + const from = date.from?.getTime(); + const to = date.to?.getTime(); + column.setFilterValue(from || to ? [from, to] : undefined); + } else if (!multiple && "getTime" in date) { + column.setFilterValue(date.getTime()); + } + }, + [column, multiple], + ); + + const onReset = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + column.setFilterValue(undefined); + }, + [column], + ); + + const hasValue = React.useMemo(() => { + if (multiple) { + if (!getIsDateRange(selectedDates)) return false; + return selectedDates.from || selectedDates.to; + } + if (!Array.isArray(selectedDates)) return false; + return selectedDates.length > 0; + }, [multiple, selectedDates]); + + const formatDateRange = React.useCallback((range: DateRange) => { + if (!range.from && !range.to) return ""; + if (range.from && range.to) { + return `${formatDate(range.from)} - ${formatDate(range.to)}`; + } + return formatDate(range.from ?? range.to); + }, []); + + const label = React.useMemo(() => { + if (multiple) { + if (!getIsDateRange(selectedDates)) return null; + + const hasSelectedDates = selectedDates.from || selectedDates.to; + const dateText = hasSelectedDates + ? formatDateRange(selectedDates) + : "Select date range"; + + return ( + + {title} + {hasSelectedDates && ( + <> + + {dateText} + + )} + + ); + } + + if (getIsDateRange(selectedDates)) return null; + + const hasSelectedDate = selectedDates.length > 0; + const dateText = hasSelectedDate + ? formatDate(selectedDates[0]) + : "Select date"; + + return ( + + {title} + {hasSelectedDate && ( + <> + + {dateText} + + )} + + ); + }, [selectedDates, multiple, formatDateRange, title]); + + return ( + + + + + + {multiple ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/components/data-table/data-table-faceted-filter.tsx b/src/components/data-table/data-table-faceted-filter.tsx new file mode 100644 index 00000000..3111382e --- /dev/null +++ b/src/components/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { Check, PlusCircle, XCircle } from "lucide-react"; +import * as React from "react"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "~/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { Separator } from "~/components/ui/separator"; +import { cn } from "~/lib/utils"; +import type { Option } from "~/types/data-table"; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + options: Option[]; + multiple?: boolean; +} + +export function DataTableFacetedFilter({ + column, + title, + options, + multiple, +}: DataTableFacetedFilterProps) { + const [open, setOpen] = React.useState(false); + + const columnFilterValue = column?.getFilterValue(); + const selectedValues = React.useMemo( + () => new Set(Array.isArray(columnFilterValue) ? columnFilterValue : []), + [columnFilterValue] + ); + + const onItemSelect = React.useCallback( + (option: Option, isSelected: boolean) => { + if (!column) return; + + if (multiple) { + const newSelectedValues = new Set(selectedValues); + if (isSelected) { + newSelectedValues.delete(option.value); + } else { + newSelectedValues.add(option.value); + } + const filterValues = Array.from(newSelectedValues); + column.setFilterValue(filterValues.length ? filterValues : undefined); + } else { + column.setFilterValue(isSelected ? undefined : [option.value]); + setOpen(false); + } + }, + [column, multiple, selectedValues], + ); + + const onReset = React.useCallback( + (event?: React.MouseEvent) => { + event?.stopPropagation(); + column?.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + + return ( + onItemSelect(option, isSelected)} + > +
+ +
+ {option.icon && } + {option.label} + {option.count && ( + + {option.count} + + )} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + onReset()} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} diff --git a/src/components/data-table/data-table-pagination.tsx b/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 00000000..1dcf5a9b --- /dev/null +++ b/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,121 @@ +import type { Table } from "@tanstack/react-table"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { cn } from "~/lib/utils"; + +interface DataTablePaginationProps extends React.ComponentProps<"div"> { + table: Table; + pageSizeOptions?: number[]; +} + +export function DataTablePagination({ + table, + pageSizeOptions = [10, 20, 30, 40, 50], + className, + ...props +}: DataTablePaginationProps) { + return ( +
+
+ {table.options.enableRowSelection ? ( + <> + {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. + + ) : ( + <> + {table.getFilteredRowModel().rows.length} row(s) + + )} +
+
+
+

Rows per page

+ +
+
+ {table.getPageCount() > 0 + ? `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}` + : "No pages"} +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/data-table/data-table-skeleton.tsx b/src/components/data-table/data-table-skeleton.tsx new file mode 100644 index 00000000..5d4328eb --- /dev/null +++ b/src/components/data-table/data-table-skeleton.tsx @@ -0,0 +1,115 @@ +import { Skeleton } from "~/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { cn } from "~/lib/utils"; + +interface DataTableSkeletonProps extends React.ComponentProps<"div"> { + columnCount: number; + rowCount?: number; + filterCount?: number; + cellWidths?: string[]; + withViewOptions?: boolean; + withPagination?: boolean; + shrinkZero?: boolean; +} + +export function DataTableSkeleton({ + columnCount, + rowCount = 10, + filterCount = 0, + cellWidths = ["auto"], + withViewOptions = true, + withPagination = true, + shrinkZero = false, + className, + ...props +}: DataTableSkeletonProps) { + const cozyCellWidths = Array.from( + { length: columnCount }, + (_, index) => cellWidths[index % cellWidths.length] ?? "auto", + ); + + return ( +
+
+
+ {filterCount > 0 + ? Array.from({ length: filterCount }).map((_, i) => ( + + )) + : null} +
+ {withViewOptions ? ( + + ) : null} +
+
+ + + {Array.from({ length: 1 }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + + + {Array.from({ length: rowCount }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + +
+
+ {withPagination ? ( +
+ +
+
+ + +
+
+ +
+
+ + + + +
+
+
+ ) : null} +
+ ); +} diff --git a/src/components/data-table/data-table-slider-filter.tsx b/src/components/data-table/data-table-slider-filter.tsx new file mode 100644 index 00000000..73bbcdd3 --- /dev/null +++ b/src/components/data-table/data-table-slider-filter.tsx @@ -0,0 +1,260 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { PlusCircle, XCircle } from "lucide-react"; +import * as React from "react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { Separator } from "~/components/ui/separator"; +import { Slider } from "~/components/ui/slider"; +import { cn } from "~/lib/utils"; + +interface Range { + min: number; + max: number; +} + +type RangeValue = [number, number]; + +function getIsValidRange(value: unknown): value is RangeValue { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "number" && + typeof value[1] === "number" + ); +} + +function parseValuesAsNumbers(value: unknown): RangeValue | undefined { + if ( + Array.isArray(value) && + value.length === 2 && + value.every( + (v) => + (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), + ) + ) { + return [Number(value[0]), Number(value[1])]; + } + + return undefined; +} + +interface DataTableSliderFilterProps { + column: Column; + title?: string; +} + +export function DataTableSliderFilter({ + column, + title, +}: DataTableSliderFilterProps) { + const id = React.useId(); + + const columnFilterValue = parseValuesAsNumbers(column.getFilterValue()); + + const defaultRange = column.columnDef.meta?.range; + const unit = column.columnDef.meta?.unit; + + const { min, max, step } = React.useMemo(() => { + let minValue = 0; + let maxValue = 100; + + if (defaultRange && getIsValidRange(defaultRange)) { + [minValue, maxValue] = defaultRange; + } else { + const values = column.getFacetedMinMaxValues(); + if (values && Array.isArray(values) && values.length === 2) { + const [facetMinValue, facetMaxValue] = values; + if ( + typeof facetMinValue === "number" && + typeof facetMaxValue === "number" + ) { + minValue = facetMinValue; + maxValue = facetMaxValue; + } + } + } + + const rangeSize = maxValue - minValue; + const step = + rangeSize <= 20 + ? 1 + : rangeSize <= 100 + ? Math.ceil(rangeSize / 20) + : Math.ceil(rangeSize / 50); + + return { min: minValue, max: maxValue, step }; + }, [column, defaultRange]); + + const range = React.useMemo((): RangeValue => { + return columnFilterValue ?? [min, max]; + }, [columnFilterValue, min, max]); + + const formatValue = React.useCallback((value: number) => { + return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + }, []); + + const onFromInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) { + column.setFilterValue([numValue, range[1]]); + } + }, + [column, min, range], + ); + + const onToInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) { + column.setFilterValue([range[0], numValue]); + } + }, + [column, max, range], + ); + + const onSliderValueChange = React.useCallback( + (value: RangeValue) => { + if (Array.isArray(value) && value.length === 2) { + column.setFilterValue(value); + } + }, + [column], + ); + + const onReset = React.useCallback( + (event?: React.MouseEvent) => { + event?.stopPropagation(); + column.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + +
+

+ {title} +

+
+ +
+ + {unit && ( + + {unit} + + )} +
+ +
+ + {unit && ( + + {unit} + + )} +
+
+ + +
+ +
+
+ ); +} diff --git a/src/components/data-table/data-table-toolbar.tsx b/src/components/data-table/data-table-toolbar.tsx new file mode 100644 index 00000000..14906811 --- /dev/null +++ b/src/components/data-table/data-table-toolbar.tsx @@ -0,0 +1,143 @@ +"use client"; + +import type { Column, Table } from "@tanstack/react-table"; +import { X } from "lucide-react"; +import * as React from "react"; + +import { DataTableDateFilter } from "~/components/data-table/data-table-date-filter"; +import { DataTableFacetedFilter } from "~/components/data-table/data-table-faceted-filter"; +import { DataTableSliderFilter } from "~/components/data-table/data-table-slider-filter"; +import { DataTableViewOptions } from "~/components/data-table/data-table-view-options"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { cn } from "~/lib/utils"; + +interface DataTableToolbarProps extends React.ComponentProps<"div"> { + table: Table; +} + +export function DataTableToolbar({ + table, + children, + className, + ...props +}: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + const columns = React.useMemo( + () => table.getAllColumns().filter((column) => column.getCanFilter()), + [table], + ); + + const onReset = React.useCallback(() => { + table.resetColumnFilters(); + }, [table]); + + return ( +
+
+ {columns.map((column) => ( + + ))} + {isFiltered && ( + + )} +
+
+ {children} + +
+
+ ); +} +interface DataTableToolbarFilterProps { + column: Column; +} + +function DataTableToolbarFilter({ + column, +}: DataTableToolbarFilterProps) { + const columnMeta = column.columnDef.meta; + + if (!columnMeta?.variant) return null; + + switch (columnMeta.variant) { + case "text": + return ( + column.setFilterValue(event.target.value)} + className="h-8 w-40 lg:w-56" + /> + ); + + case "number": + return ( +
+ column.setFilterValue(event.target.value)} + className={cn("h-8 w-[120px]", columnMeta.unit && "pr-8")} + /> + {columnMeta.unit && ( + + {columnMeta.unit} + + )} +
+ ); + + case "range": + return ( + + ); + + case "date": + case "dateRange": + return ( + + ); + + case "select": + case "multiSelect": + return ( + + ); + + default: + return null; + } +} diff --git a/src/components/data-table/data-table-view-options.tsx b/src/components/data-table/data-table-view-options.tsx new file mode 100644 index 00000000..51c9725d --- /dev/null +++ b/src/components/data-table/data-table-view-options.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { Table } from "@tanstack/react-table"; +import { Check, Settings2 } from "lucide-react"; +import * as React from "react"; +import { Button } from "~/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "~/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; + +interface DataTableViewOptionsProps + extends React.ComponentProps { + table: Table; + disabled?: boolean; +} + +export function DataTableViewOptions({ + table, + disabled, + ...props +}: DataTableViewOptionsProps) { + const columns = React.useMemo( + () => + table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && column.getCanHide(), + ), + [table], + ); + + return ( + + + + + + + + + No columns found. + + {columns.map((column) => ( + + column.toggleVisibility(!column.getIsVisible()) + } + > + + {column.columnDef.meta?.label ?? column.id} + + + + ))} + + + + + + ); +} diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx new file mode 100644 index 00000000..27557309 --- /dev/null +++ b/src/components/data-table/data-table.tsx @@ -0,0 +1,101 @@ +import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"; +import type * as React from "react"; + +import { DataTablePagination } from "~/components/data-table/data-table-pagination"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { getCommonPinningStyles } from "~/lib/data-table"; +import { cn } from "~/lib/utils"; + +interface DataTableProps extends React.ComponentProps<"div"> { + table: TanstackTable; + actionBar?: React.ReactNode; +} + +export function DataTable({ + table, + actionBar, + children, + className, + ...props +}: DataTableProps) { + return ( +
+ {children} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + {actionBar && + table.getFilteredSelectedRowModel().rows.length > 0 && + actionBar} +
+
+ ); +} diff --git a/src/components/discount-dialog.tsx b/src/components/discount-dialog.tsx index 61efc54a..38b2f2a9 100644 --- a/src/components/discount-dialog.tsx +++ b/src/components/discount-dialog.tsx @@ -39,8 +39,8 @@ export function DiscountDialog({ setError(null); try { - const { valid } = await validateAffiliateCodeFn({ data: { code: discountCode.trim() } }); - + const { data: { valid } } = await validateAffiliateCodeFn({ data: { code: discountCode.trim() } }); + if (valid) { setIsValid(true); onApplyDiscount(discountCode.trim()); diff --git a/src/components/emails/affiliate-payout-failed-email.tsx b/src/components/emails/affiliate-payout-failed-email.tsx new file mode 100644 index 00000000..c11c04ca --- /dev/null +++ b/src/components/emails/affiliate-payout-failed-email.tsx @@ -0,0 +1,236 @@ +import { + Html, + Head, + Body, + Container, + Section, + Text, + Preview, + Heading, + Hr, + Button, +} from "@react-email/components"; +import { EmailHeader } from "./email-header"; +import { EmailFooter } from "./email-footer"; +import { env } from "~/utils/env"; + +interface AffiliatePayoutFailedEmailProps { + affiliateName: string; + errorMessage: string; + failureDate: string; +} + +export function AffiliatePayoutFailedEmail({ + affiliateName, + errorMessage, + failureDate, +}: AffiliatePayoutFailedEmailProps) { + const previewText = `Action required: Your affiliate payout could not be processed`; + const affiliateDashboardUrl = `${env.HOST_NAME}/affiliates`; + + return ( + + + {previewText} + + + + + {/* Main Content */} +
+ {/* Error Badge */} +
+ ACTION REQUIRED +
+ + + Payout Issue, {affiliateName} + + + + We attempted to send your affiliate payout, but encountered an + issue. Please review your Stripe account settings. + + +
+ + {/* Error Details */} +
+ Error Details + {errorMessage} + Occurred on: {failureDate} +
+ +
+ + {/* Action Steps */} +
+ What to do next: + + 1. Log into your Stripe account and verify your account details + + + 2. Ensure your bank account information is correct and verified + + + 3. Check if there are any pending verification requirements + + + 4. Visit your affiliate dashboard to check your account status + +
+ + {/* CTA Button */} +
+ +
+ + + If you continue to experience issues, please contact our support + team for assistance. + +
+ + +
+ + + ); +} + +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', +}; + +const container = { + margin: "0 auto", + padding: "20px 0 48px", + width: "580px", + maxWidth: "100%", +}; + +const contentSection = { + padding: "32px 24px", + backgroundColor: "#ffffff", + borderRadius: "8px", + border: "1px solid #e1e8ed", + marginBottom: "16px", +}; + +const badgeContainer = { + textAlign: "center" as const, + marginBottom: "20px", +}; + +const badge = { + display: "inline-block", + backgroundColor: "#ef4444", + color: "#ffffff", + padding: "6px 12px", + borderRadius: "4px", + fontSize: "12px", + fontWeight: "600", + letterSpacing: "0.5px", + textTransform: "uppercase" as const, +}; + +const heading = { + fontSize: "28px", + fontWeight: "700", + color: "#1a1a1a", + marginBottom: "16px", + lineHeight: "1.3", + textAlign: "center" as const, + margin: "0 0 16px 0", +}; + +const description = { + fontSize: "16px", + lineHeight: "1.6", + color: "#374151", + marginBottom: "24px", + textAlign: "center" as const, +}; + +const hr = { + borderColor: "#e1e8ed", + margin: "24px 0", +}; + +const errorSection = { + backgroundColor: "#fef2f2", + borderRadius: "8px", + padding: "16px", + border: "1px solid #fecaca", +}; + +const errorLabel = { + fontSize: "12px", + fontWeight: "600", + color: "#991b1b", + textTransform: "uppercase" as const, + letterSpacing: "0.5px", + margin: "0 0 8px 0", +}; + +const errorMessageStyle = { + fontSize: "14px", + color: "#b91c1c", + margin: "0 0 8px 0", + fontFamily: "monospace", + wordBreak: "break-word" as const, +}; + +const errorDate = { + fontSize: "12px", + color: "#6b7280", + margin: "0", +}; + +const stepsSection = { + padding: "0", +}; + +const stepsHeading = { + fontSize: "16px", + fontWeight: "600", + color: "#1a1a1a", + margin: "0 0 12px 0", +}; + +const stepItem = { + fontSize: "14px", + lineHeight: "1.6", + color: "#374151", + margin: "0 0 8px 0", + paddingLeft: "8px", +}; + +const buttonContainer = { + textAlign: "center" as const, + margin: "24px 0 16px 0", +}; + +const button = { + backgroundColor: "#3b82f6", + color: "#ffffff", + padding: "14px 28px", + borderRadius: "6px", + fontSize: "16px", + fontWeight: "600", + textDecoration: "none", + display: "inline-block", + textAlign: "center" as const, +}; + +const supportText = { + fontSize: "14px", + lineHeight: "1.5", + color: "#6b7280", + textAlign: "center" as const, + margin: "0", +}; diff --git a/src/components/emails/affiliate-payout-success-email.tsx b/src/components/emails/affiliate-payout-success-email.tsx new file mode 100644 index 00000000..d60c9842 --- /dev/null +++ b/src/components/emails/affiliate-payout-success-email.tsx @@ -0,0 +1,199 @@ +import { + Html, + Head, + Body, + Container, + Section, + Text, + Preview, + Heading, + Hr, +} from "@react-email/components"; +import { EmailHeader } from "./email-header"; +import { EmailFooter } from "./email-footer"; + +interface AffiliatePayoutSuccessEmailProps { + affiliateName: string; + payoutAmount: string; + payoutDate: string; + stripeTransferId: string; +} + +export function AffiliatePayoutSuccessEmail({ + affiliateName, + payoutAmount, + payoutDate, + stripeTransferId, +}: AffiliatePayoutSuccessEmailProps) { + const previewText = `Your affiliate payout of ${payoutAmount} has been sent!`; + + return ( + + + {previewText} + + + + + {/* Main Content */} +
+ {/* Success Badge */} +
+ PAYOUT SENT +
+ + + Great news, {affiliateName}! + + + + Your affiliate payout has been processed and sent to your + connected Stripe account. + + +
+ + {/* Payout Details */} +
+
+ Amount + {payoutAmount} +
+
+ Date + {payoutDate} +
+
+ Transfer ID + {stripeTransferId} +
+
+ +
+ + + The funds should appear in your Stripe balance shortly. Depending + on your Stripe payout schedule, it may take a few business days + for the funds to reach your bank account. + + + + Thank you for being a valued affiliate partner! + +
+ + +
+ + + ); +} + +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', +}; + +const container = { + margin: "0 auto", + padding: "20px 0 48px", + width: "580px", + maxWidth: "100%", +}; + +const contentSection = { + padding: "32px 24px", + backgroundColor: "#ffffff", + borderRadius: "8px", + border: "1px solid #e1e8ed", + marginBottom: "16px", +}; + +const badgeContainer = { + textAlign: "center" as const, + marginBottom: "20px", +}; + +const badge = { + display: "inline-block", + backgroundColor: "#10b981", + color: "#ffffff", + padding: "6px 12px", + borderRadius: "4px", + fontSize: "12px", + fontWeight: "600", + letterSpacing: "0.5px", + textTransform: "uppercase" as const, +}; + +const heading = { + fontSize: "28px", + fontWeight: "700", + color: "#1a1a1a", + marginBottom: "16px", + lineHeight: "1.3", + textAlign: "center" as const, + margin: "0 0 16px 0", +}; + +const description = { + fontSize: "16px", + lineHeight: "1.6", + color: "#374151", + marginBottom: "24px", + textAlign: "center" as const, +}; + +const hr = { + borderColor: "#e1e8ed", + margin: "24px 0", +}; + +const detailsSection = { + padding: "16px 0", +}; + +const detailRow = { + display: "flex", + justifyContent: "space-between", + marginBottom: "12px", +}; + +const detailLabel = { + fontSize: "14px", + color: "#6b7280", + margin: "0", +}; + +const detailValue = { + fontSize: "16px", + fontWeight: "600", + color: "#1a1a1a", + margin: "0", +}; + +const detailValueSmall = { + fontSize: "14px", + fontWeight: "500", + color: "#374151", + margin: "0", + fontFamily: "monospace", +}; + +const infoText = { + fontSize: "14px", + lineHeight: "1.6", + color: "#6b7280", + textAlign: "center" as const, + margin: "0 0 16px 0", +}; + +const thankYouText = { + fontSize: "16px", + lineHeight: "1.6", + color: "#10b981", + textAlign: "center" as const, + fontWeight: "600", + margin: "0", +}; diff --git a/src/components/feature-flag.tsx b/src/components/feature-flag.tsx index 825d6611..0813af6b 100644 --- a/src/components/feature-flag.tsx +++ b/src/components/feature-flag.tsx @@ -1,15 +1,17 @@ import { useQuery } from "@tanstack/react-query"; -import { isFeatureEnabledForUserFn } from "~/fn/app-settings"; +import { isFeatureEnabledForUserFn, getFeatureFlagEnabledFn } from "~/fn/app-settings"; import { type FlagKey } from "~/config"; interface FeatureFlagProps { flag: FlagKey; children: React.ReactNode; fallback?: React.ReactNode; + /** If true, respects flag state even for admins (no admin override). Useful for admin panel. */ + strict?: boolean; } -export function FeatureFlag({ flag, children, fallback }: FeatureFlagProps) { - const { isEnabled, isLoading, isError } = useFeatureFlag(flag); +export function FeatureFlag({ flag, children, fallback, strict = false }: FeatureFlagProps) { + const { isEnabled, isLoading, isError } = useFeatureFlag(flag, { strict }); if (isError) { console.error("Feature flag check failed for:", flag); @@ -23,10 +25,19 @@ export function FeatureFlag({ flag, children, fallback }: FeatureFlagProps) { return children; } -export function useFeatureFlag(flag: FlagKey) { +interface UseFeatureFlagOptions { + /** If true, respects flag state even for admins (no admin override). Useful for admin panel. */ + strict?: boolean; +} + +export function useFeatureFlag(flag: FlagKey, options: UseFeatureFlagOptions = {}) { + const { strict = false } = options; + const { data: isEnabled, isLoading, isError } = useQuery({ - queryKey: ["featureFlag", flag], - queryFn: () => isFeatureEnabledForUserFn({ data: { flagKey: flag } }), + queryKey: ["featureFlag", flag, { strict }], + queryFn: () => strict + ? getFeatureFlagEnabledFn({ data: { flagKey: flag } }) + : isFeatureEnabledForUserFn({ data: { flagKey: flag } }), staleTime: 5 * 60 * 1000, // 5 minutes }); diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx new file mode 100644 index 00000000..9dcd6af4 --- /dev/null +++ b/src/components/ui/button-group.tsx @@ -0,0 +1,76 @@ +"use client" + +import * as React from "react" +import { cn } from "~/lib/utils" + +function ButtonGroup({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
button]:rounded-none [&>button]:border-l-0 [&>button:first-child]:rounded-l-md [&>button:first-child]:border-l [&>button:last-child]:rounded-r-md [&>[data-slot=input-group]]:rounded-none [&>[data-slot=input-group]:first-child]:rounded-l-md [&>[data-slot=input-group]:last-child]:rounded-r-md", + className + )} + {...props} + /> + ) +} + +function InputGroup({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function InputGroupInput({ + className, + ...props +}: React.ComponentProps<"input">) { + return ( + + ) +} + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"span"> & { + align?: "inline-start" | "inline-end" +}) { + return ( + + ) +} + +export { ButtonGroup, InputGroup, InputGroupInput, InputGroupAddon } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 11ea2ecc..356d2ae2 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,59 +1,65 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils" const buttonVariants = cva( - "cursor-pointer rounded-lg inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: - "w-full justify-center px-4 py-2 bg-theme-600 text-white hover:shadow-elevation-3 hover:bg-theme-600 hover:text-white", + default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "border border-red-500 bg-transparent text-red-500 shadow-sm hover:bg-red-50 hover:text-red-600 dark:border-red-400 dark:text-red-400 dark:hover:bg-red-950/50 dark:hover:text-red-300", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - "border border-theme-200 bg-background shadow-sm hover:bg-theme-100 hover:text-theme-700 dark:border-theme-800 dark:hover:bg-theme-950 dark:hover:text-theme-300", - "gray-outline": - "border border-border bg-transparent text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground dark:border-border dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: - "bg-theme-100 text-theme-900 shadow-sm hover:bg-theme-200 dark:bg-theme-800 dark:text-theme-100 dark:hover:bg-theme-700", + "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: - "hover:bg-theme-600 hover:text-white dark:hover:bg-theme-600 dark:hover:text-white", - link: "text-theme-500 underline-offset-4 hover:underline dark:text-theme-400", - glass: "glass text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-white/10 border border-slate-200/60 dark:border-white/[0.07]", + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", cyan: "btn-cyan text-white shadow-lg shadow-cyan-500/20", + glass: + "glass text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-white/10 border border-slate-200/60 dark:border-white/[0.07]", }, size: { - default: "h-9 px-4 py-2", - sm: "h-8 px-3 text-xs", - lg: "h-10 px-8", - icon: "h-9 w-9", + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", }, }, - defaultVariants: { variant: "default", size: "default" }, + defaultVariants: { + variant: "default", + size: "default", + }, } -); +) -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean; -} +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ( - - ); - } -); -Button.displayName = "Button"; + return ( + + ) +} -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..0f7e9b20 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,218 @@ +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, +} from "react-day-picker" + +import { cn } from "~/lib/utils" +import { Button, buttonVariants } from "~/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "flex gap-4 flex-col md:flex-row relative", + defaultClassNames.months + ), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn( + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_next + ), + month_caption: cn( + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute bg-popover inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + defaultClassNames.weekday + ), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn( + "select-none w-(--cell-size)", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] select-none text-muted-foreground", + defaultClassNames.week_number + ), + day: cn( + "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + +
+
+ + Joined {formatShortDate(affiliate.createdAt)} +
+
+
+ + + {/* Financial Stats - Compact List */} +
+
+
+
+ + Unpaid Balance +
+ + {formatCurrency(affiliate.unpaidBalance)} + +
+
+
+ + Paid Out +
+ + {formatCurrency(affiliate.paidAmount)} + +
+
+
+ + Lifetime Earnings +
+ + {formatCurrency(affiliate.totalEarnings)} + +
+
+
+ + Total Referrals +
+ + {affiliate.totalReferrals} + +
+
+
+ + {/* Configuration Section */} +
+

+ Settings +

+
+ {/* Commission Rate */} +
+
+ + Commission Rate +
+ { + setNewCommissionRate(val); + if (!editingRate) setEditingRate(true); + }} + onSave={handleSaveCommissionRate} + suffix="%" + step={1} + min={0} + max={100} + isPending={updateCommissionRateMutation.isPending} + hasChanges={editingRate && newCommissionRate !== affiliate.commissionRate.toString()} + inputWidth="w-20" + /> +
+ + {/* Partner Status */} +
+
+ + Partner Status +
+ +
+
+
+ + {/* Payout Connection - Consolidated */} +
+

+ Payout +

+
+
+
+ +
+
+ + {isStripe ? "Stripe Connect" : "Payment Link"} + + {isStripe && ( +
+
+ + {isStripeConnected ? "Connected" : affiliate.stripeAccountStatus === "onboarding" ? "Onboarding" : "Not Connected"} + +
+ )} +
+
+ {isStripe ? ( + affiliate.lastStripeSync ? `Last synced ${formatDate(affiliate.lastStripeSync)}` : "Never synced" + ) : ( + affiliate.paymentLink ? ( + {affiliate.paymentLink} + ) : "No payment link set" + )} +
+
+
+ {!isStripe && affiliate.paymentLink && ( + + )} +
+
+
+ + {/* Activity Timeline */} +
+

+ Activity +

+
+ {/* Timeline line */} +
+ + {/* Loading state */} + {isLoadingActivity && ( +
+
+ Loading activity... +
+ )} + + {/* Error state */} + {activityError && !isLoadingActivity && ( +
+ Failed to load activity history +
+ )} + + {/* Timeline items */} + {!isLoadingActivity && !activityError && ( +
+ {/* Real referral/conversion history */} + {allReferrals.map((referral) => ( +
+
+
+

+ Referral converted + {referral.purchaserName && • {referral.purchaserName}} +

+

+ {formatCurrency(referral.commission)} commission + {referral.isPaid && " • Paid"} + {referral.createdAt && ` • ${formatDate(referral.createdAt)}`} +

+
+
+ ))} + + {/* Real payout history */} + {allPayouts.map((payout) => ( +
+
+
+

+ {payout.status === "completed" ? "Payout completed" : + payout.status === "pending" ? "Payout pending" : "Payout failed"} +

+

+ {formatCurrency(payout.amount)} via {payout.paymentMethod} + {payout.paidAt && ` • ${formatDate(payout.paidAt)}`} +

+
+
+ ))} + + {isStripe && affiliate.stripeConnectAccountId && ( +
+
+
+

Stripe account connected

+

+ {affiliate.lastStripeSync ? formatDate(affiliate.lastStripeSync) : "Recently"} +

+
+
+ )} + +
+
+
+

Partner account created

+

{formatDate(affiliate.createdAt)}

+
+
+ + {/* Show indicator if there's more activity */} + {hasMoreActivity && ( +
+
+ + + more activity... + +
+ )} +
+ )} +
+
+ + + ); +} diff --git a/src/routes/admin/-affiliates-components/affiliates-columns.tsx b/src/routes/admin/-affiliates-components/affiliates-columns.tsx new file mode 100644 index 00000000..be7ca781 --- /dev/null +++ b/src/routes/admin/-affiliates-components/affiliates-columns.tsx @@ -0,0 +1,304 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { DataTableColumnHeader } from "~/components/data-table/data-table-column-header"; +import { + CheckCircle, + CreditCard, + Eye, + UserX, + Link, +} from "lucide-react"; +import { cn } from "~/lib/utils"; +import { sanitizeImageUrl } from "~/utils/url-sanitizer"; + +export type AffiliateRow = { + id: number; + userId: number; + userEmail: string | null; + userName: string | null; + userImage: string | null; + affiliateCode: string; + paymentLink: string | null; + paymentMethod: string; + commissionRate: number; + totalEarnings: number; + paidAmount: number; + unpaidBalance: number; + isActive: boolean; + createdAt: Date; + stripeConnectAccountId: string | null; + stripeAccountStatus: string; + stripeChargesEnabled: boolean | null; + stripePayoutsEnabled: boolean | null; + stripeDetailsSubmitted: boolean | null; + lastStripeSync: Date | null; + totalReferrals: number; + lastReferralDate: Date | null; +}; + +const formatCurrency = (cents: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cents / 100); +}; + + +interface AffiliateColumnsOptions { + onCopyLink: (link: string) => void; + onViewLink: (link: string) => void; + onRecordPayout: (affiliate: AffiliateRow) => void; + onToggleStatus: (affiliateId: number, currentStatus: boolean) => void; + onViewDetails?: (affiliate: AffiliateRow) => void; +} + +export function getAffiliateColumns( + options: AffiliateColumnsOptions +): ColumnDef[] { + return [ + { + id: "affiliate", + accessorKey: "userName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const affiliate = row.original; + const displayName = + affiliate.userName || affiliate.userEmail?.split("@")[0] || "Unknown"; + const initial = displayName.charAt(0).toUpperCase(); + + return ( +
+ {/* Avatar with status indicator */} +
+ {sanitizeImageUrl(affiliate.userImage) ? ( + {displayName} + ) : ( +
+ {initial} +
+ )} +
+
+ + {/* Info */} +
+
+ + {displayName} + + + {affiliate.affiliateCode} + +
+ + {affiliate.userEmail || "No email"} + +
+
+ ); + }, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const nameA = rowA.original.userName || rowA.original.userEmail || ""; + const nameB = rowB.original.userName || rowB.original.userEmail || ""; + return nameA.localeCompare(nameB); + }, + enableColumnFilter: true, + meta: { + label: "Affiliate", + placeholder: "Search affiliates...", + variant: "text" as const, + }, + }, + { + id: "referrals", + accessorKey: "totalReferrals", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const affiliate = row.original; + + return ( +
+ + {affiliate.totalReferrals} + +
+ ); + }, + enableSorting: true, + size: 100, + meta: { + label: "Referrals", + }, + }, + { + id: "payout", + accessorKey: "paymentMethod", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const affiliate = row.original; + const isStripe = affiliate.paymentMethod === "stripe"; + const isStripeConnected = + isStripe && + affiliate.stripeAccountStatus === "active" && + affiliate.stripePayoutsEnabled; + + return ( +
+ {isStripe ? ( + isStripeConnected ? ( + + + Stripe + + ) : affiliate.stripeAccountStatus === "onboarding" ? ( + + + Pending + + ) : ( + + + Disconnected + + ) + ) : ( + + + Manual + + )} +
+ ); + }, + enableSorting: true, + size: 130, + sortingFn: (rowA, rowB) => { + return rowA.original.paymentMethod.localeCompare(rowB.original.paymentMethod); + }, + meta: { + label: "Payout", + }, + }, + { + id: "rate", + accessorKey: "commissionRate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const affiliate = row.original; + + return ( +
+ + {affiliate.commissionRate}% + +
+ ); + }, + enableSorting: true, + size: 80, + meta: { + label: "Rate", + }, + }, + { + id: "balance", + accessorKey: "unpaidBalance", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const affiliate = row.original; + + return ( +
+
0 + ? "text-orange-400" + : "text-foreground" + )} + > + {formatCurrency(affiliate.unpaidBalance)} +
+
+ {formatCurrency(affiliate.totalEarnings)} + {" "}lifetime +
+
+ ); + }, + enableSorting: true, + size: 140, + meta: { + label: "Balance", + }, + }, + { + id: "actions", + header: () => null, + cell: ({ row }) => { + const affiliate = row.original; + + return ( +
+ + +
+ ); + }, + enableSorting: false, + size: 80, + }, + ]; +} diff --git a/src/routes/admin/-components/admin-nav.tsx b/src/routes/admin/-components/admin-nav.tsx index 1b1650f8..fd530331 100644 --- a/src/routes/admin/-components/admin-nav.tsx +++ b/src/routes/admin/-components/admin-nav.tsx @@ -16,6 +16,7 @@ import { AlertCircle, TrendingUp, Video, + DollarSign, } from "lucide-react"; import { useFeatureFlag } from "~/components/feature-flag"; @@ -105,6 +106,12 @@ const navigation: NavigationItem[] = [ icon: TrendingUp, category: "business", }, + { + name: "Pricing", + href: "/admin/pricing", + icon: DollarSign, + category: "business", + }, // Communications { @@ -116,8 +123,8 @@ const navigation: NavigationItem[] = [ // System { - name: "Settings", - href: "/admin/settings", + name: "Feature Flags", + href: "/admin/feature-flags", icon: Settings, category: "system", }, diff --git a/src/routes/admin/affiliates.tsx b/src/routes/admin/affiliates.tsx index 85d05639..63c3b5e3 100644 --- a/src/routes/admin/affiliates.tsx +++ b/src/routes/admin/affiliates.tsx @@ -1,10 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useState, useMemo, useEffect, useCallback } from "react"; +import { assertIsAdminFn } from "~/fn/auth"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Textarea } from "~/components/ui/textarea"; - import { Dialog, DialogContent, @@ -27,61 +27,45 @@ import { adminGetAllAffiliatesFn, adminToggleAffiliateStatusFn, adminRecordPayoutFn, + adminProcessAutomaticPayoutsFn, } from "~/fn/affiliates"; +import { + getAffiliateCommissionRateFn, + setAffiliateCommissionRateFn, + getAffiliateMinimumPayoutFn, + setAffiliateMinimumPayoutFn, +} from "~/fn/app-settings"; +import { AFFILIATE_CONFIG } from "~/config"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { DollarSign, Users, - ExternalLink, - MoreVertical, - Calendar, - CreditCard, CheckCircle, - XCircle, AlertCircle, - Copy, + Zap, + RefreshCw, + TrendingUp, + Search, + Clock, } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; +import { NumberInputWithControls } from "~/components/blocks/number-input-with-controls"; import { PageHeader } from "./-components/page-header"; import { Page } from "./-components/page"; +import { DataTable } from "~/components/data-table/data-table"; +import { DataTableViewOptions } from "~/components/data-table/data-table-view-options"; +import { useDataTable } from "~/hooks/use-data-table"; +import { + getAffiliateColumns, + type AffiliateRow, +} from "./-affiliates-components/affiliates-columns"; +import { AffiliateDetailsSheet } from "./-affiliates-components/affiliate-details-sheet"; +import { FeatureFlag } from "~/components/feature-flag"; // Skeleton components function CountSkeleton() { - return
; -} - -function AffiliateCardSkeleton() { - return ( -
-
-
-
-
-
-
-
- {[...Array(4)].map((_, i) => ( -
-
-
-
- ))} -
-
-
-
-
-
-
- ); + return
; } const payoutSchema = z.object({ @@ -93,12 +77,143 @@ const payoutSchema = z.object({ type PayoutFormValues = z.infer; +function useCommissionRate() { + const queryClient = useQueryClient(); + const [localRate, setLocalRate] = useState(""); + const [hasLocalChanges, setHasLocalChanges] = useState(false); + + const { data: commissionRate, isLoading, error } = useQuery({ + queryKey: ["affiliateCommissionRate"], + queryFn: () => getAffiliateCommissionRateFn(), + }); + + const displayRate = hasLocalChanges + ? localRate + : (commissionRate?.toString() ?? AFFILIATE_CONFIG.DEFAULT_COMMISSION_RATE.toString()); + + const updateMutation = useMutation({ + mutationFn: (rate: number) => + setAffiliateCommissionRateFn({ data: { rate } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["affiliateCommissionRate"] }); + toast.success("Commission rate updated successfully"); + setHasLocalChanges(false); + }, + onError: (error) => { + toast.error("Failed to update commission rate"); + console.error("Failed to update commission rate:", error); + }, + }); + + const handleRateChange = (value: string) => { + setLocalRate(value); + setHasLocalChanges(true); + }; + + const handleSave = () => { + const rate = parseInt(displayRate, 10); + if (isNaN(rate) || rate < 0 || rate > 100) { + toast.error("Commission rate must be between 0 and 100"); + return; + } + updateMutation.mutate(rate); + }; + + const handleReset = () => { + setLocalRate( + commissionRate?.toString() ?? AFFILIATE_CONFIG.DEFAULT_COMMISSION_RATE.toString() + ); + setHasLocalChanges(false); + }; + + return { + displayRate, + isLoading, + isPending: updateMutation.isPending, + hasLocalChanges, + handleRateChange, + handleSave, + handleReset, + }; +} + +function useMinimumPayout() { + const queryClient = useQueryClient(); + const [localAmount, setLocalAmount] = useState(""); + const [hasLocalChanges, setHasLocalChanges] = useState(false); + + const { data: minimumPayout, isLoading } = useQuery({ + queryKey: ["affiliateMinimumPayout"], + queryFn: () => getAffiliateMinimumPayoutFn(), + }); + + // Display in dollars (cents / 100) + const displayAmount = hasLocalChanges + ? localAmount + : ((minimumPayout ?? AFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT) / 100).toString(); + + const updateMutation = useMutation({ + mutationFn: (amount: number) => + setAffiliateMinimumPayoutFn({ data: { amount } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["affiliateMinimumPayout"] }); + toast.success("Minimum payout updated successfully"); + setHasLocalChanges(false); + }, + onError: (error) => { + toast.error("Failed to update minimum payout"); + console.error("Failed to update minimum payout:", error); + }, + }); + + const handleAmountChange = (value: string) => { + setLocalAmount(value); + setHasLocalChanges(true); + }; + + const handleSave = () => { + const dollars = parseInt(displayAmount, 10); + if (isNaN(dollars) || dollars < 0) { + toast.error("Minimum payout must be $0 or more"); + return; + } + // Convert dollars to cents + updateMutation.mutate(dollars * 100); + }; + + const handleReset = () => { + setLocalAmount( + ((minimumPayout ?? AFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT) / 100).toString() + ); + setHasLocalChanges(false); + }; + + return { + displayAmount, + isLoading, + isPending: updateMutation.isPending, + hasLocalChanges, + handleAmountChange, + handleSave, + handleReset, + }; +} + export const Route = createFileRoute("/admin/affiliates")({ + beforeLoad: () => assertIsAdminFn(), loader: ({ context }) => { context.queryClient.ensureQueryData({ queryKey: ["admin", "affiliates"], queryFn: () => adminGetAllAffiliatesFn(), }); + context.queryClient.ensureQueryData({ + queryKey: ["affiliateCommissionRate"], + queryFn: () => getAffiliateCommissionRateFn(), + }); + context.queryClient.ensureQueryData({ + queryKey: ["affiliateMinimumPayout"], + queryFn: () => getAffiliateMinimumPayoutFn(), + }); }, component: AdminAffiliates, }); @@ -110,11 +225,58 @@ function AdminAffiliates() { ); const [payoutAffiliateName, setPayoutAffiliateName] = useState(""); const [payoutUnpaidBalance, setPayoutUnpaidBalance] = useState(0); - - const { data: affiliates, isLoading } = useQuery({ + const [searchQuery, setSearchQuery] = useState(""); + const [selectedAffiliate, setSelectedAffiliate] = useState(null); + const [detailsSheetOpen, setDetailsSheetOpen] = useState(false); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + const { data: affiliatesResponse, isLoading } = useQuery({ queryKey: ["admin", "affiliates"], queryFn: () => adminGetAllAffiliatesFn(), }); + const affiliates = affiliatesResponse?.data; + + // Keep selectedAffiliate in sync when affiliates list is refreshed + useEffect(() => { + if (selectedAffiliate && affiliates) { + const updated = affiliates.find((a) => a.id === selectedAffiliate.id); + if (updated) { + setSelectedAffiliate(updated as AffiliateRow); + } + } + }, [affiliates, selectedAffiliate?.id]); + + // Filter affiliates based on debounced search query + const filteredAffiliates = useMemo(() => { + if (!affiliates) return []; + if (!debouncedSearchQuery.trim()) return affiliates; + + const query = debouncedSearchQuery.toLowerCase(); + return affiliates.filter((affiliate) => { + const searchableFields = [ + affiliate.userName, + affiliate.userEmail, + affiliate.affiliateCode, + affiliate.isActive ? "active" : "inactive", + affiliate.paymentMethod, + String(affiliate.id), + ]; + return searchableFields.some( + (field) => field && field.toLowerCase().includes(query) + ); + }); + }, [affiliates, debouncedSearchQuery]); + + const commissionRateState = useCommissionRate(); + const minimumPayoutState = useMinimumPayout(); const form = useForm({ resolver: zodResolver(payoutSchema), @@ -158,23 +320,42 @@ function AdminAffiliates() { }, }); - const handleToggleStatus = async ( + const autoPayoutMutation = useMutation({ + mutationFn: adminProcessAutomaticPayoutsFn, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ["admin", "affiliates"] }); + toast.success("Auto-Payouts Triggered", { + description: `Processed ${result.data.processed} affiliates: ${result.data.successful} successful, ${result.data.failed} failed`, + }); + }, + onError: (error) => { + toast.error("Auto-Payout Failed", { + description: error.message || "Failed to trigger automatic payouts.", + }); + }, + }); + + const handleToggleStatus = useCallback(async ( affiliateId: number, currentStatus: boolean ) => { await toggleStatusMutation.mutateAsync({ data: { affiliateId, isActive: !currentStatus }, }); + }, [toggleStatusMutation]); + + const handleTriggerAutoPayouts = async () => { + await autoPayoutMutation.mutateAsync({ data: undefined }); }; - const openPayoutDialog = (affiliate: any) => { + const openPayoutDialog = useCallback((affiliate: AffiliateRow) => { setPayoutAffiliateId(affiliate.id); setPayoutAffiliateName( affiliate.userName || affiliate.userEmail || "Unknown" ); setPayoutUnpaidBalance(affiliate.unpaidBalance); - form.setValue("amount", affiliate.unpaidBalance / 100); // Convert cents to dollars - }; + form.setValue("amount", affiliate.unpaidBalance / 100); + }, [form]); const onSubmitPayout = async (values: PayoutFormValues) => { if (!payoutAffiliateId) return; @@ -182,7 +363,7 @@ function AdminAffiliates() { await recordPayoutMutation.mutateAsync({ data: { affiliateId: payoutAffiliateId, - amount: Math.round(values.amount * 100), // Convert dollars to cents + amount: Math.round(values.amount * 100), paymentMethod: values.paymentMethod, transactionId: values.transactionId || undefined, notes: values.notes || undefined, @@ -197,21 +378,12 @@ function AdminAffiliates() { }).format(cents / 100); }; - const formatDate = (date: Date | string | null) => { - if (!date) return "Never"; - return new Date(date).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - }; - - const copyToClipboard = (text: string) => { + const copyToClipboard = useCallback((text: string) => { navigator.clipboard.writeText(text); toast.success("Copied!", { description: "Link copied to clipboard.", }); - }; + }, []); // Calculate totals const totals = affiliates?.reduce( @@ -224,6 +396,44 @@ function AdminAffiliates() { { totalUnpaid: 0, totalPaid: 0, totalEarnings: 0, activeCount: 0 } ) || { totalUnpaid: 0, totalPaid: 0, totalEarnings: 0, activeCount: 0 }; + // Handler for viewing affiliate details - wrapped in useCallback + const handleViewDetails = useCallback((affiliate: AffiliateRow) => { + setSelectedAffiliate(affiliate); + setDetailsSheetOpen(true); + }, []); + + // Handler for opening links in new tab - wrapped in useCallback + const handleViewLink = useCallback((link: string) => { + window.open(link, "_blank"); + }, []); + + // Columns for DataTable + const columns = useMemo( + () => + getAffiliateColumns({ + onCopyLink: copyToClipboard, + onViewLink: handleViewLink, + onRecordPayout: openPayoutDialog, + onToggleStatus: handleToggleStatus, + onViewDetails: handleViewDetails, + }), + [copyToClipboard, handleViewLink, openPayoutDialog, handleToggleStatus, handleViewDetails] + ); + + // Data table setup + const { table } = useDataTable({ + data: (filteredAffiliates as AffiliateRow[]) ?? [], + columns, + pageCount: Math.ceil((filteredAffiliates?.length ?? 0) / 10), + initialState: { + pagination: { pageSize: 10, pageIndex: 0 }, + sorting: [{ id: "unpaidBalance", desc: true }], + }, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + return ( - {/* Stats Overview */} + {/* Top Controls Row - Search + Multiplier + Batch Settle */}
- {/* Total Unpaid */} -
-
-
-
- Total Unpaid -
-
- -
-
-
- {isLoading ? ( - + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-10 h-11 bg-card/60 border-border/50" + /> +
+ + {/* Right side controls */} +
+ {/* Minimum Payout - only shown when custom payment links are enabled */} + + + + + {/* Commission Rate */} + + + {/* Batch Settle Button - only for Payment Link affiliates */} + + + +
+
+ + {/* Stats Cards */} +
+ {/* Escrow/Unpaid */} +
+
+ + Escrow / Unpaid + +
+
-

Pending payouts

+
+
+ {isLoading ? : formatCurrency(totals.totalUnpaid)}
- {/* Total Paid */} -
-
-
-
- Total Paid -
-
- -
-
-
- {isLoading ? : formatCurrency(totals.totalPaid)} + {/* Settled Volume */} +
+
+ + Settled Volume + +
+
-

Lifetime payouts

+
+
+ {isLoading ? : formatCurrency(totals.totalPaid)}
- {/* Total Earnings */} -
-
-
-
- Total Earnings -
-
- -
-
-
- {isLoading ? ( - - ) : ( - formatCurrency(totals.totalEarnings) - )} + {/* Gross Generated */} +
+
+ + Gross Generated + +
+
-

- Generated for affiliates -

+
+
+ {isLoading ? : formatCurrency(totals.totalEarnings)}
- {/* Active Affiliates */} -
-
-
-
- Active Affiliates -
-
- -
+ {/* Active Nodes */} +
+
+ + Active Nodes + +
+
-
- {isLoading ? : totals.activeCount} -
-

- of {isLoading ? "..." : affiliates?.length || 0} total -

+
+
+ {isLoading ? : totals.activeCount}
- {/* Affiliates List */} + {/* Affiliates Data Table */}
-
-

All Affiliates

-

- View and manage all affiliate accounts -

+ {/* Table toolbar */} +
+
-
- {isLoading ? ( -
- {[...Array(3)].map((_, idx) => ( - - ))} -
- ) : affiliates?.length === 0 ? ( -
- -

No affiliates registered yet

-
- ) : ( -
- {affiliates?.map((affiliate, index) => ( -
- {/* Subtle hover glow effect */} -
- -
-
-
- - {/* Show public name based on useDisplayName setting */} - {affiliate.useDisplayName === false && affiliate.userRealName - ? affiliate.userRealName - : affiliate.userName || affiliate.userEmail || "Unknown User"} - {/* Show alternative name for admin context */} - {affiliate.useDisplayName === false && affiliate.userName && ( - - (alias: {affiliate.userName}) - - )} - {affiliate.useDisplayName !== false && affiliate.userRealName && ( - - (real: {affiliate.userRealName}) - - )} - - {affiliate.isActive ? ( - - - Active - - ) : ( - - - Inactive - - )} - - {affiliate.affiliateCode} - -
- -
-
-
- Unpaid Balance -
-
- {formatCurrency(affiliate.unpaidBalance)} -
-
-
-
- Total Paid -
-
- {formatCurrency(affiliate.paidAmount)} -
-
-
-
- Total Earnings -
-
- {formatCurrency(affiliate.totalEarnings)} -
-
-
-
- Sales Count -
-
- {affiliate.totalReferrals} -
-
-
- -
-
- - Joined {formatDate(affiliate.createdAt)} -
-
- - - Last sale {formatDate(affiliate.lastReferralDate)} - -
-
-
- - - - - - - window.open(affiliate.paymentLink, "_blank") - } - > - - View Payment Link - - copyToClipboard(affiliate.paymentLink)} - > - - Copy Payment Link - - - openPayoutDialog(affiliate)} - disabled={affiliate.unpaidBalance < 5000} - className={ - affiliate.unpaidBalance < 5000 ? "opacity-50" : "" - } - > - - Record Payout - {affiliate.unpaidBalance < 5000 && ( - - Min $50 - - )} - - - handleToggleStatus(affiliate.id, affiliate.isActive) - } - > - {affiliate.isActive ? ( - <> - - Deactivate - - ) : ( - <> - - Activate - - )} - - - -
-
- ))} -
- )} +
+
+ {/* Affiliate Details Sheet */} + + + {/* Payout Dialog - only shown when custom payment links are enabled */} + !open && setPayoutAffiliateId(null)} + onOpenChange={(open) => { + if (!open) { + setPayoutAffiliateId(null); + form.reset(); + } + }} > @@ -636,7 +722,7 @@ function AdminAffiliates() { > {recordPayoutMutation.isPending ? (
-
+
Recording...
) : ( @@ -648,6 +734,7 @@ function AdminAffiliates() {
+
); } diff --git a/src/routes/admin/feature-flags.tsx b/src/routes/admin/feature-flags.tsx new file mode 100644 index 00000000..fe644609 --- /dev/null +++ b/src/routes/admin/feature-flags.tsx @@ -0,0 +1,279 @@ +import { useState, useMemo } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { PageHeader } from "~/routes/admin/-components/page-header"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + toggleEarlyAccessModeFn, + toggleAgentsFeatureFn, + toggleLaunchKitsFeatureFn, + toggleAffiliatesFeatureFn, + toggleBlogFeatureFn, + toggleNewsFeatureFn, + toggleFeatureFlagFn, +} from "~/fn/app-settings"; +import { getAllFeatureFlagsFn } from "~/fn/feature-flags"; +import { assertIsAdminFn } from "~/fn/auth"; +import { toast } from "sonner"; +import { Page } from "./-components/page"; +import { TargetingDialog } from "./settings/-components/targeting-dialog"; +import { FeatureFlagCard } from "./settings/-components/feature-flag-card"; +import { FLAGS, type FlagKey, DISPLAYED_FLAGS, FLAG_GROUPS, type FlagGroup, type TargetMode } from "~/config"; +import { Input } from "~/components/ui/input"; +import { Search } from "lucide-react"; + +type FlagState = { enabled: boolean; targeting: { targetMode: TargetMode; users: unknown[] } | undefined }; + +/** Toggle functions for each flag */ +const FLAG_TOGGLE_FNS: Record Promise> = { + [FLAGS.EARLY_ACCESS_MODE]: toggleEarlyAccessModeFn, + [FLAGS.AGENTS_FEATURE]: toggleAgentsFeatureFn, + [FLAGS.ADVANCED_AGENTS_FEATURE]: (params) => + toggleFeatureFlagFn({ data: { flagKey: FLAGS.ADVANCED_AGENTS_FEATURE, enabled: params.data.enabled } }), + [FLAGS.LAUNCH_KITS_FEATURE]: toggleLaunchKitsFeatureFn, + [FLAGS.AFFILIATES_FEATURE]: toggleAffiliatesFeatureFn, + [FLAGS.AFFILIATE_CUSTOM_PAYMENT_LINK]: (params) => + toggleFeatureFlagFn({ data: { flagKey: FLAGS.AFFILIATE_CUSTOM_PAYMENT_LINK, enabled: params.data.enabled } }), + [FLAGS.AFFILIATE_DISCOUNT_SPLIT]: (params) => + toggleFeatureFlagFn({ data: { flagKey: FLAGS.AFFILIATE_DISCOUNT_SPLIT, enabled: params.data.enabled } }), + [FLAGS.BLOG_FEATURE]: toggleBlogFeatureFn, + [FLAGS.NEWS_FEATURE]: toggleNewsFeatureFn, + [FLAGS.VIDEO_SEGMENT_CONTENT_TABS]: (params) => + toggleFeatureFlagFn({ data: { flagKey: FLAGS.VIDEO_SEGMENT_CONTENT_TABS, enabled: params.data.enabled } }), +}; + +export const Route = createFileRoute("/admin/feature-flags")({ + beforeLoad: () => assertIsAdminFn(), + component: SettingsPage, + loader: ({ context }) => { + // Prefetch all feature flags in one request + context.queryClient.ensureQueryData({ + queryKey: ["allFeatureFlags"], + queryFn: () => getAllFeatureFlagsFn(), + }); + }, +}); + +function useToggleFlag(flagKey: FlagKey) { + const queryClient = useQueryClient(); + const flagConfig = DISPLAYED_FLAGS.find((f) => f.key === flagKey); + + const toggleMutation = useMutation({ + mutationFn: FLAG_TOGGLE_FNS[flagKey], + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["allFeatureFlags"] }); + toast.success(`${flagConfig?.title ?? `Feature`} updated successfully`); + }, + onError: (error) => { + toast.error(`Failed to update ${flagConfig?.title ?? `feature`}`); + console.error(`Failed to update ${flagKey}:`, error); + }, + }); + + return { + toggle: (checked: boolean) => toggleMutation.mutate({ data: { enabled: checked } }), + isPending: toggleMutation.isPending, + }; +} + +/** Wrapper component that handles toggle mutation for each flag */ +function FeatureFlagCardWrapper({ + flag, + state, + animationDelay, + onConfigureTargeting, + featureStates, + flagConfigs, +}: { + flag: (typeof DISPLAYED_FLAGS)[number]; + state: FlagState | undefined; + animationDelay: string; + onConfigureTargeting: () => void; + featureStates: Record; + flagConfigs: Record; +}) { + const { toggle, isPending } = useToggleFlag(flag.key); + + return ( + + ) +} + +/** Order of groups for display */ +const GROUP_ORDER: FlagGroup[] = [ + FLAG_GROUPS.PLATFORM, + FLAG_GROUPS.AI_AGENTS, + FLAG_GROUPS.LAUNCH_KITS, + FLAG_GROUPS.AFFILIATES, + FLAG_GROUPS.CONTENT, +]; + +function SettingsPage() { + const [targetingDialog, setTargetingDialog] = useState<{ + open: boolean; + flagKey: FlagKey | null; + flagName: string; + }>({ open: false, flagKey: null, flagName: "" }); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedGroup, setSelectedGroup] = useState(null); + + // Single query for all feature flags - no hooks violation! + const { data: flagStates } = useQuery({ + queryKey: ["allFeatureFlags"], + queryFn: () => getAllFeatureFlagsFn(), + }); + + // Create featureStates record for dependency checking + const featureStatesRecord: Record = Object.fromEntries( + DISPLAYED_FLAGS.map((flag) => [flag.key, flagStates?.[flag.key]?.enabled]) + ); + + // Create flagConfigs record for dependency titles + const flagConfigsRecord: Record = Object.fromEntries( + DISPLAYED_FLAGS.map((flag) => [flag.key, { title: flag.title }]) + ); + + // Filter and group flags + const filteredAndGroupedFlags = useMemo(() => { + const query = searchQuery.toLowerCase().trim(); + + // Filter flags based on search and selected group + const filtered = DISPLAYED_FLAGS.filter((flag) => { + // Filter by group if selected + if (selectedGroup && flag.group !== selectedGroup) return false; + + // Filter by search query + if (!query) return true; + return ( + flag.title.toLowerCase().includes(query) || + flag.description.toLowerCase().includes(query) || + flag.key.toLowerCase().includes(query) || + flag.group.toLowerCase().includes(query) + ); + }); + + // Group flags by their group property + const grouped = GROUP_ORDER.map((group) => ({ + group, + flags: filtered.filter((flag) => flag.group === group), + })).filter((g) => g.flags.length > 0); + + return grouped; + }, [searchQuery, selectedGroup]); + + const openTargetingDialog = (flagKey: FlagKey, flagName: string) => { + setTargetingDialog({ open: true, flagKey, flagName }); + }; + + // Track global animation index across groups + let animationIndex = 0; + + return ( + + + + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + {/* Group Filter Tabs */} +
+ + {GROUP_ORDER.map((group) => ( + + ))} +
+ + {/* Feature Flags Grid */} +
+ {filteredAndGroupedFlags.flatMap((groupData) => + groupData.flags.map((flag) => { + const currentIndex = animationIndex++; + return ( + openTargetingDialog(flag.key, flag.title)} + featureStates={featureStatesRecord} + flagConfigs={flagConfigsRecord} + /> + ); + }) + )} +
+ + {filteredAndGroupedFlags.length === 0 && (searchQuery || selectedGroup) && ( +
+ No feature flags found{searchQuery ? ` matching "${searchQuery}"` : ""}{selectedGroup ? ` in ${selectedGroup}` : ""} +
+ )} + + {targetingDialog.flagKey && ( + setTargetingDialog((prev) => ({ ...prev, open }))} + flagKey={targetingDialog.flagKey} + flagName={targetingDialog.flagName} + /> + )} +
+ ); +} diff --git a/src/routes/admin/pricing.tsx b/src/routes/admin/pricing.tsx new file mode 100644 index 00000000..3679f5df --- /dev/null +++ b/src/routes/admin/pricing.tsx @@ -0,0 +1,287 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; +import { Input } from "~/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Label } from "~/components/ui/label"; +import { toast } from "sonner"; +import { + getPricingSettingsFn, + updatePricingSettingsFn, +} from "~/fn/app-settings"; +import { assertIsAdminFn } from "~/fn/auth"; +import { Tag, Users } from "lucide-react"; +import { PageHeader } from "./-components/page-header"; +import { Page } from "./-components/page"; +import { NumberInputWithControls } from "~/components/blocks/number-input-with-controls"; + +export const Route = createFileRoute("/admin/pricing")({ + beforeLoad: () => assertIsAdminFn(), + loader: ({ context }) => { + context.queryClient.ensureQueryData({ + queryKey: ["pricingSettings"], + queryFn: () => getPricingSettingsFn(), + }); + }, + component: PricingPage, +}); + +function PricingPage() { + const queryClient = useQueryClient(); + + const { data: pricing } = useSuspenseQuery({ + queryKey: ["pricingSettings"], + queryFn: () => getPricingSettingsFn(), + }); + + const [currentPrice, setCurrentPrice] = useState(pricing.currentPrice.toString()); + const [originalPrice, setOriginalPrice] = useState(pricing.originalPrice.toString()); + const [promoLabel, setPromoLabel] = useState(pricing.promoLabel); + const [hasCurrentPriceChanges, setHasCurrentPriceChanges] = useState(false); + const [hasOriginalPriceChanges, setHasOriginalPriceChanges] = useState(false); + const [hasPromoLabelChanges, setHasPromoLabelChanges] = useState(false); + + // Sync state when server data changes + useEffect(() => { + setCurrentPrice(pricing.currentPrice.toString()); + setOriginalPrice(pricing.originalPrice.toString()); + setPromoLabel(pricing.promoLabel); + setHasCurrentPriceChanges(false); + setHasOriginalPriceChanges(false); + setHasPromoLabelChanges(false); + }, [pricing]); + + const updateCurrentPriceMutation = useMutation({ + mutationFn: (price: number) => updatePricingSettingsFn({ data: { currentPrice: price } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["pricingSettings"] }); + toast.success("Current price updated"); + setHasCurrentPriceChanges(false); + }, + onError: () => { + toast.error("Failed to update current price"); + }, + }); + + const updateOriginalPriceMutation = useMutation({ + mutationFn: (price: number) => updatePricingSettingsFn({ data: { originalPrice: price } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["pricingSettings"] }); + toast.success("Original price updated"); + setHasOriginalPriceChanges(false); + }, + onError: () => { + toast.error("Failed to update original price"); + }, + }); + + const updatePromoLabelMutation = useMutation({ + mutationFn: (label: string) => updatePricingSettingsFn({ data: { promoLabel: label } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["pricingSettings"] }); + toast.success("Promo label updated"); + setHasPromoLabelChanges(false); + }, + onError: () => { + toast.error("Failed to update promo label"); + }, + }); + + const handleCurrentPriceChange = (value: string) => { + setCurrentPrice(value); + setHasCurrentPriceChanges(value !== pricing.currentPrice.toString()); + }; + + const handleOriginalPriceChange = (value: string) => { + setOriginalPrice(value); + setHasOriginalPriceChanges(value !== pricing.originalPrice.toString()); + }; + + const handlePromoLabelChange = (value: string) => { + setPromoLabel(value); + setHasPromoLabelChanges(value !== pricing.promoLabel); + }; + + const currentPriceNum = parseInt(currentPrice) || 0; + const originalPriceNum = parseInt(originalPrice) || 0; + + const discountPercentage = + originalPriceNum > currentPriceNum + ? Math.round(((originalPriceNum - currentPriceNum) / originalPriceNum) * 100) + : 0; + + return ( + + + + {/* Controls Row */} +
+ {/* Current Price */} +
+ + updateCurrentPriceMutation.mutate(parseInt(currentPrice) || 0)} + prefix="$" + step={10} + min={0} + isPending={updateCurrentPriceMutation.isPending} + hasChanges={hasCurrentPriceChanges} + inputWidth="w-28" + /> +
+ + {/* Original Price */} +
+ + updateOriginalPriceMutation.mutate(parseInt(originalPrice) || 0)} + prefix="$" + step={10} + min={0} + isPending={updateOriginalPriceMutation.isPending} + hasChanges={hasOriginalPriceChanges} + inputWidth="w-28" + /> +
+ + {/* Promo Label */} +
+ +
+ handlePromoLabelChange(e.target.value)} + placeholder="e.g., Cyber Monday deal" + className="h-10" + /> + +
+
+
+ + {/* Preview Cards */} +
+ {/* Preview - Direct Purchase */} + + + + + Direct Purchase Preview + + + How it appears without affiliate link + + + +
+ {originalPriceNum > currentPriceNum && ( +
+ Regular price{" "} + ${originalPriceNum} +
+ )} +
+ ${currentPriceNum} +
+ {promoLabel && ( +
+ {promoLabel} + {discountPercentage > 0 && ` - ${discountPercentage}% OFF`} +
+ )} +
+ One-time payment, lifetime access +
+
+
+
+ + {/* Preview - With Affiliate */} + + + + + Affiliate Link Preview + + + Example with 12% extra affiliate discount + + + + {(() => { + const exampleAffiliateDiscount = 12; + const affiliatePrice = Math.round(currentPriceNum * (1 - exampleAffiliateDiscount / 100)); + const totalDiscount = originalPriceNum > 0 + ? Math.round(((originalPriceNum - affiliatePrice) / originalPriceNum) * 100) + : 0; + + return ( +
+
+ Regular price{" "} + ${originalPriceNum} +
+
+ ${affiliatePrice} +
+ {/* Promo as main */} + {promoLabel && ( +
+ {promoLabel} + {discountPercentage > 0 && ` - ${discountPercentage}% OFF`} +
+ )} + {/* Affiliate as extra */} +
+ + {exampleAffiliateDiscount}% extra via affiliate +
+
+ One-time payment, lifetime access +
+ + {totalDiscount > 0 && ( +
+ + Total savings: {totalDiscount}% off original price + +
+ )} +
+ ); + })()} +
+
+
+
+ ); +} diff --git a/src/routes/admin/route.tsx b/src/routes/admin/route.tsx index b800adfa..0bdeb1a2 100644 --- a/src/routes/admin/route.tsx +++ b/src/routes/admin/route.tsx @@ -58,7 +58,7 @@ function AdminLayout() { {/* Main content */}
-
+
diff --git a/src/routes/admin/settings.tsx b/src/routes/admin/settings.tsx deleted file mode 100644 index ddcef031..00000000 --- a/src/routes/admin/settings.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useState } from "react"; -import { createFileRoute } from "@tanstack/react-router"; -import { PageHeader } from "~/routes/admin/-components/page-header"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { - toggleEarlyAccessModeFn, - toggleAgentsFeatureFn, - toggleLaunchKitsFeatureFn, - toggleAffiliatesFeatureFn, - toggleBlogFeatureFn, - toggleNewsFeatureFn, - toggleFeatureFlagFn, -} from "~/fn/app-settings"; -import { getAllFeatureFlagsFn } from "~/fn/feature-flags"; -import { assertIsAdminFn } from "~/fn/auth"; -import { toast } from "sonner"; -import { Page } from "./-components/page"; -import { TargetingDialog } from "./settings/-components/targeting-dialog"; -import { FeatureFlagCard } from "./settings/-components/feature-flag-card"; -import { FLAGS, type FlagKey, DISPLAYED_FLAGS } from "~/config"; - -/** Toggle functions for each flag */ -const FLAG_TOGGLE_FNS: Record Promise> = { - [FLAGS.EARLY_ACCESS_MODE]: toggleEarlyAccessModeFn, - [FLAGS.AGENTS_FEATURE]: toggleAgentsFeatureFn, - [FLAGS.ADVANCED_AGENTS_FEATURE]: (params) => - toggleFeatureFlagFn({ data: { flagKey: FLAGS.ADVANCED_AGENTS_FEATURE, enabled: params.data.enabled } }), - [FLAGS.LAUNCH_KITS_FEATURE]: toggleLaunchKitsFeatureFn, - [FLAGS.AFFILIATES_FEATURE]: toggleAffiliatesFeatureFn, - [FLAGS.BLOG_FEATURE]: toggleBlogFeatureFn, - [FLAGS.NEWS_FEATURE]: toggleNewsFeatureFn, - [FLAGS.VIDEO_SEGMENT_CONTENT_TABS]: () => Promise.resolve(undefined), -}; - -export const Route = createFileRoute("/admin/settings")({ - beforeLoad: () => assertIsAdminFn(), - component: SettingsPage, - loader: ({ context }) => { - // Prefetch all feature flags in one request - context.queryClient.ensureQueryData({ - queryKey: ["allFeatureFlags"], - queryFn: () => getAllFeatureFlagsFn(), - }); - }, -}); - -function useToggleFlag(flagKey: FlagKey) { - const queryClient = useQueryClient(); - const flagConfig = DISPLAYED_FLAGS.find((f) => f.key === flagKey); - - const toggleMutation = useMutation({ - mutationFn: FLAG_TOGGLE_FNS[flagKey], - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["allFeatureFlags"] }); - toast.success(`${flagConfig?.title ?? "Feature"} updated successfully`); - }, - onError: (error) => { - toast.error(`Failed to update ${flagConfig?.title ?? "feature"}`); - console.error(`Failed to update ${flagKey}:`, error); - }, - }); - - return { - toggle: (checked: boolean) => toggleMutation.mutate({ data: { enabled: checked } }), - isPending: toggleMutation.isPending, - }; -} - -/** Wrapper component that handles toggle mutation for each flag */ -function FeatureFlagCardWrapper({ - flag, - state, - animationDelay, - onConfigureTargeting, - featureStates, - flagConfigs, -}: { - flag: (typeof DISPLAYED_FLAGS)[number]; - state: { enabled: boolean; targeting: unknown } | undefined; - animationDelay: string; - onConfigureTargeting: () => void; - featureStates: Record; - flagConfigs: Record; -}) { - const { toggle, isPending } = useToggleFlag(flag.key); - - return ( - - ); -} - -function SettingsPage() { - const [targetingDialog, setTargetingDialog] = useState<{ - open: boolean; - flagKey: FlagKey | null; - flagName: string; - }>({ open: false, flagKey: null, flagName: "" }); - - // Single query for all feature flags - no hooks violation! - const { data: flagStates } = useQuery({ - queryKey: ["allFeatureFlags"], - queryFn: () => getAllFeatureFlagsFn(), - }); - - // Create featureStates record for dependency checking - const featureStatesRecord: Record = Object.fromEntries( - DISPLAYED_FLAGS.map((flag) => [flag.key, flagStates?.[flag.key]?.enabled]) - ); - - // Create flagConfigs record for dependency titles - const flagConfigsRecord: Record = Object.fromEntries( - DISPLAYED_FLAGS.map((flag) => [flag.key, { title: flag.title }]) - ); - - const openTargetingDialog = (flagKey: FlagKey, flagName: string) => { - setTargetingDialog({ open: true, flagKey, flagName }); - }; - - return ( - - - - {/* Feature Flags Section */} -
- {DISPLAYED_FLAGS.map((flag, index) => ( - openTargetingDialog(flag.key, flag.title)} - featureStates={featureStatesRecord} - flagConfigs={flagConfigsRecord} - /> - ))} -
- - {targetingDialog.flagKey && ( - setTargetingDialog((prev) => ({ ...prev, open }))} - flagKey={targetingDialog.flagKey} - flagName={targetingDialog.flagName} - /> - )} -
- ); -} diff --git a/src/routes/affiliate-dashboard.tsx b/src/routes/affiliate-dashboard.tsx index 32cd947d..a186f65e 100644 --- a/src/routes/affiliate-dashboard.tsx +++ b/src/routes/affiliate-dashboard.tsx @@ -1,11 +1,7 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; import { assertFeatureEnabled } from "~/lib/feature-flags"; -import { - useSuspenseQuery, - useMutation, - useQueryClient, -} from "@tanstack/react-query"; -import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; import { Button, buttonVariants } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { @@ -22,8 +18,11 @@ import { getAffiliateDashboardFn, updateAffiliatePaymentLinkFn, checkIfUserIsAffiliateFn, + refreshStripeAccountStatusFn, + disconnectStripeAccountFn, + updateAffiliateDiscountRateFn, } from "~/fn/affiliates"; -import { authenticatedMiddleware } from "~/lib/auth"; +import { getPricingSettingsFn } from "~/fn/app-settings"; import { Copy, DollarSign, @@ -38,9 +37,20 @@ import { CreditCard, ArrowUpRight, ArrowDownRight, + CheckCircle, + XCircle, + AlertCircle, + AlertTriangle, + RefreshCw, + Clock, + Link as LinkIcon, + Zap, + Unlink, + Gift, } from "lucide-react"; import { cn } from "~/lib/utils"; import { env } from "~/utils/env"; +import { AFFILIATE_CONFIG, PRICING_CONFIG } from "~/config"; import { Dialog, DialogContent, @@ -49,6 +59,17 @@ import { DialogTitle, DialogTrigger, } from "~/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -63,13 +84,33 @@ import { } from "~/components/ui/form"; import { publicEnv } from "~/utils/env-public"; import { assertAuthenticatedFn } from "~/fn/auth"; +import { isFeatureEnabledForUserFn } from "~/fn/app-settings"; import { motion } from "framer-motion"; +import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; +import { Slider } from "~/components/ui/slider"; -const paymentLinkSchema = z.object({ - paymentLink: z.url("Please provide a valid URL"), +const paymentFormSchema = z.object({ + paymentMethod: z.enum(["link", "stripe"]), + paymentLink: z.string().optional(), +}).refine((data) => { + if (data.paymentMethod === "link") { + if (!data.paymentLink || data.paymentLink.length === 0) { + return false; + } + try { + new URL(data.paymentLink); + return true; + } catch { + return false; + } + } + return true; +}, { + message: "Please provide a valid payment URL", + path: ["paymentLink"], }); -type PaymentLinkFormValues = z.infer; +type PaymentFormValues = z.infer; // Animation variants const containerVariants = { @@ -125,28 +166,51 @@ const statsVariants = { }, }; +// PERF: If dashboard load times become slow, consider splitting into useSuspenseQuery +// calls with Suspense boundaries for progressive loading (stats → referrals → payouts). +// See: https://tanstack.com/query/latest/docs/framework/react/guides/suspense export const Route = createFileRoute("/affiliate-dashboard")({ beforeLoad: async () => { await assertFeatureEnabled("AFFILIATES_FEATURE"); await assertAuthenticatedFn(); - }, - loader: async ({ context }) => { - // First check if user is an affiliate - const affiliateCheck = await context.queryClient.ensureQueryData({ - queryKey: ["affiliate", "check"], - queryFn: () => checkIfUserIsAffiliateFn(), - }); + // Check if onboarding is complete - redirect if not + const { data: affiliateCheck } = await checkIfUserIsAffiliateFn(); if (!affiliateCheck.isAffiliate) { - return { isAffiliate: false }; + throw redirect({ to: "/affiliates" }); } + if (!affiliateCheck.isOnboardingComplete) { + throw redirect({ to: "/affiliate-onboarding" }); + } + }, + loader: async ({ context }) => { + // Fetch dashboard data, feature flags, and pricing settings in parallel + const [dashboardResponse, discountSplitEnabled, customPaymentLinkEnabled, pricingSettings] = await Promise.all([ + context.queryClient.ensureQueryData({ + queryKey: ["affiliate", "dashboard"], + queryFn: () => getAffiliateDashboardFn(), + }), + context.queryClient.ensureQueryData({ + queryKey: ["featureFlag", "AFFILIATE_DISCOUNT_SPLIT"], + queryFn: () => isFeatureEnabledForUserFn({ data: { flagKey: "AFFILIATE_DISCOUNT_SPLIT" } }), + }), + context.queryClient.ensureQueryData({ + queryKey: ["featureFlag", "AFFILIATE_CUSTOM_PAYMENT_LINK"], + queryFn: () => isFeatureEnabledForUserFn({ data: { flagKey: "AFFILIATE_CUSTOM_PAYMENT_LINK" } }), + }), + context.queryClient.ensureQueryData({ + queryKey: ["pricing", "settings"], + queryFn: () => getPricingSettingsFn(), + }), + ]); - // If they are an affiliate, get dashboard data - const data = await context.queryClient.ensureQueryData({ - queryKey: ["affiliate", "dashboard"], - queryFn: () => getAffiliateDashboardFn(), - }); - return { isAffiliate: true, dashboard: data }; + return { + isAffiliate: true, + dashboard: dashboardResponse.data, + discountSplitEnabled, + customPaymentLinkEnabled, + pricingSettings, + }; }, component: AffiliateDashboard, }); @@ -154,43 +218,98 @@ export const Route = createFileRoute("/affiliate-dashboard")({ function AffiliateDashboard() { const loaderData = Route.useLoaderData(); - // If user is not an affiliate, show error message - if (!loaderData.isAffiliate) { - return ; - } + // The beforeLoad guard already redirects non-affiliates, so dashboard is guaranteed to exist + // Use the dashboard data, feature flags, and pricing settings from loader + const dashboard = loaderData.dashboard!; + const discountSplitEnabled = loaderData.discountSplitEnabled ?? true; + const customPaymentLinkEnabled = loaderData.customPaymentLinkEnabled ?? true; + const pricingSettings = loaderData.pricingSettings ?? { currentPrice: PRICING_CONFIG.CURRENT_PRICE, originalPrice: PRICING_CONFIG.ORIGINAL_PRICE, promoLabel: "" }; const user = useAuth(); const queryClient = useQueryClient(); const [copied, setCopied] = useState(false); const [editPaymentOpen, setEditPaymentOpen] = useState(false); + const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false); + const [localDiscountRate, setLocalDiscountRate] = useState(dashboard.affiliate.discountRate); - // Use the dashboard data from loader - const dashboard = loaderData.dashboard; + // Sync local discount rate with server data when it changes + useEffect(() => { + setLocalDiscountRate(dashboard.affiliate.discountRate); + }, [dashboard.affiliate.discountRate]); - const form = useForm({ - resolver: zodResolver(paymentLinkSchema), + const form = useForm({ + resolver: zodResolver(paymentFormSchema), defaultValues: { - paymentLink: dashboard.affiliate.paymentLink, + paymentMethod: dashboard.affiliate.paymentMethod || "link", + paymentLink: dashboard.affiliate.paymentLink || "", }, }); + const paymentMethod = form.watch("paymentMethod"); + const updatePaymentMutation = useMutation({ mutationFn: updateAffiliatePaymentLinkFn, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["affiliate", "dashboard"] }); - toast.success("Payment Link Updated", { - description: "Your payment link has been successfully updated.", + toast.success("Payment Method Updated", { + description: "Your payment method has been successfully updated.", }); setEditPaymentOpen(false); }, onError: (error) => { toast.error("Update Failed", { - description: error.message || "Failed to update payment link.", + description: error.message || "Failed to update payment method.", + }); + }, + }); + + const refreshStripeStatusMutation = useMutation({ + mutationFn: refreshStripeAccountStatusFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["affiliate", "dashboard"] }); + toast.success("Status Refreshed", { + description: "Your Stripe Connect status has been updated.", + }); + }, + onError: (error) => { + toast.error("Refresh Failed", { + description: error.message || "Failed to refresh Stripe account status.", + }); + }, + }); + + const disconnectStripeAccountMutation = useMutation({ + mutationFn: disconnectStripeAccountFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["affiliate", "dashboard"] }); + toast.success("Stripe Disconnected", { + description: "Your Stripe Connect account has been disconnected. You can now set up a payment link.", + }); + setDisconnectDialogOpen(false); + }, + onError: (error) => { + toast.error("Disconnect Failed", { + description: error.message || "Failed to disconnect Stripe account.", + }); + }, + }); + + const updateDiscountRateMutation = useMutation({ + mutationFn: updateAffiliateDiscountRateFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["affiliate", "dashboard"] }); + toast.success("Discount Split Updated", { + description: "Your commission split has been updated.", + }); + }, + onError: (error) => { + toast.error("Update Failed", { + description: error.message || "Failed to update discount split.", }); }, }); - const onSubmitPaymentLink = async (values: PaymentLinkFormValues) => { + const onSubmitPaymentForm = async (values: PaymentFormValues) => { await updatePaymentMutation.mutateAsync({ data: values }); }; @@ -272,6 +391,41 @@ function AffiliateDashboard() { initial="hidden" animate="visible" > + {/* Migration Prompt - Show when custom payment link is disabled but affiliate uses it */} + {!customPaymentLinkEnabled && dashboard.affiliate.paymentMethod === "link" && ( + + + + + + Payment Method Update Required + + + Custom payment links are no longer supported. Please connect a Stripe account to continue receiving payouts. + + + + + + + + )} + {/* Affiliate Link Card */} @@ -290,7 +444,11 @@ function AffiliateDashboard() { readOnly className="font-mono text-sm" /> -
+ {customPaymentLinkEnabled && ( - Update Payment Link + Update Payment Method - Enter your new payment link for receiving affiliate - payouts + Choose how you want to receive affiliate payouts
( - Payment Link + Payment Method - + + {customPaymentLinkEnabled && ( + + )} + + - - PayPal, Venmo, or other payment link - - )} /> + + {customPaymentLinkEnabled && paymentMethod === "link" ? ( + ( + + Payment Link + + + + + PayPal, Venmo, or other payment link + + + + )} + /> + ) : ( +
+
+ +

Stripe Connect

+
+

+ {dashboard.affiliate.stripeAccountStatus === "active" && dashboard.affiliate.stripePayoutsEnabled + ? "Your Stripe account is connected and payouts are enabled" + : dashboard.affiliate.stripeConnectAccountId + ? "Complete your Stripe setup to enable payouts" + : "Connect your Stripe account to enable automatic payouts"} +

+
+ )} +
+ )}
- - -
- - Minimum Payout:{" "} - - $50.00 + + {/* Payment Method Badge */} +
+
+ + Payment Method: + + + {dashboard.affiliate.paymentMethod === "stripe" + ? "Stripe Connect" + : "Payment Link"} + +
+ {/* Refresh Button - only for onboarding/restricted states */} + {dashboard.affiliate.paymentMethod === "stripe" && + (dashboard.affiliate.stripeAccountStatus === "onboarding" || + dashboard.affiliate.stripeAccountStatus === "restricted") && ( + + )}
+ + {/* Payment Link Info (for link method) */} + {dashboard.affiliate.paymentMethod === "link" && dashboard.affiliate.paymentLink && ( +
+ + Payment Link: + + {dashboard.affiliate.paymentLink.startsWith("https://") ? ( + + {dashboard.affiliate.paymentLink} + + + ) : ( + + {dashboard.affiliate.paymentLink} + + )} +
+ )} + + {/* Stripe Account Status (for stripe method) */} + {dashboard.affiliate.paymentMethod === "stripe" && ( + <> + {/* Payout Error Banner */} + {dashboard.affiliate.lastPayoutError && ( +
+
+
+ +
+
+

Payout Failed

+

+ {dashboard.affiliate.lastPayoutError} +

+ {dashboard.affiliate.lastPayoutErrorAt && ( +

+ + Failed on {formatDate(dashboard.affiliate.lastPayoutErrorAt)} +

+ )} +

+ Please verify your Stripe Connect account settings or contact support if this issue persists. +

+
+
+
+ )} + + {/* Not Started State */} + {dashboard.affiliate.stripeAccountStatus === "not_started" && ( +
+
+
+ +
+
+

Connect Your Stripe Account

+

+ Set up Stripe to receive automatic payouts on every sale +

+
+
+ +
+ )} + + {/* Onboarding State */} + {dashboard.affiliate.stripeAccountStatus === "onboarding" && ( +
+
+
+ +
+
+

Complete Your Onboarding

+

+ Your Stripe account setup is incomplete. Please complete onboarding to enable payouts. +

+
+
+ + + Complete Onboarding + +
+ )} + + {/* Active State - simple confirmation with account info */} + {dashboard.affiliate.stripeAccountStatus === "active" && dashboard.affiliate.stripePayoutsEnabled && ( +
+
+
+ +

Instant Payouts Active

+
+ {dashboard.affiliate.stripeAccountType && ( + + {dashboard.affiliate.stripeAccountType} + + )} +
+ {dashboard.affiliate.stripeAccountName && ( +

+ {dashboard.affiliate.stripeAccountName} +

+ )} +

+ Commissions are automatically transferred with each sale +

+
+ )} + + {/* Restricted Account Warning - this one IS important */} + {dashboard.affiliate.stripeAccountStatus === "restricted" && ( +
+
+ +

Account Restricted

+
+

+ Your Stripe account has restrictions. Please visit your Stripe dashboard to resolve any issues. +

+
+ )} + + )} + + {/* Minimum Payout Info - only for manual link payouts */} + {dashboard.affiliate.paymentMethod === "link" && ( +
+ + Minimum Payout:{" "} + + + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format((AFFILIATE_CONFIG.MINIMUM_PAYOUT ?? 5000) / 100)} + +
+ )}
@@ -686,13 +1177,31 @@ function AffiliateDashboard() {
)}
- - - Paid - + {payout.status === "completed" ? ( + + + Paid + + ) : payout.status === "pending" ? ( + + + Pending + + ) : ( + + + Failed + + )}
))}
@@ -704,52 +1213,3 @@ function AffiliateDashboard() {
); } - -function NotAffiliateError() { - return ( -
-
- -
- -
- -

- Not an Affiliate -

- -

- You are not registered as an affiliate. You need to join our - affiliate program to access this dashboard. -

- -
- - Return Home - - - - Join Affiliate Program - -
-
-
-
- ); -} diff --git a/src/routes/affiliate-onboarding.tsx b/src/routes/affiliate-onboarding.tsx new file mode 100644 index 00000000..b3492c31 --- /dev/null +++ b/src/routes/affiliate-onboarding.tsx @@ -0,0 +1,685 @@ +import { createFileRoute, useRouter, useSearch } from "@tanstack/react-router"; +import { useState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { assertFeatureEnabled } from "~/lib/feature-flags"; +import { Button, buttonVariants } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Checkbox } from "~/components/ui/checkbox"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { toast } from "sonner"; +import { useAuth } from "~/hooks/use-auth"; +import { + registerAffiliateFn, + checkIfUserIsAffiliateFn, +} from "~/fn/affiliates"; +import { isFeatureEnabledForUserFn, getPublicAffiliateCommissionRateFn, getPublicAffiliateMinimumPayoutFn } from "~/fn/app-settings"; +import { AFFILIATE_CONFIG } from "~/config"; +import { useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + DollarSign, + CreditCard, + Link as LinkIcon, + CheckCircle, + ArrowRight, + ArrowLeft, + Zap, + ExternalLink, + Shield, +} from "lucide-react"; +import { cn } from "~/lib/utils"; +import { motion, AnimatePresence } from "framer-motion"; + +// Wizard steps +type WizardStep = "payment-method" | "payment-setup" | "terms" | "complete"; + +// Payment method types +type PaymentMethodType = "stripe-express" | "stripe-oauth" | "link"; + +const paymentLinkSchema = z.object({ + paymentLink: z.string().url("Please enter a valid URL"), +}); + +const searchSchema = z.object({ + step: z.enum(["payment-method", "payment-setup", "terms", "complete"]).optional(), + method: z.enum(["stripe-express", "stripe-oauth", "link"]).optional(), + stripeComplete: z.boolean().optional(), +}); + +export const Route = createFileRoute("/affiliate-onboarding")({ + validateSearch: searchSchema, + beforeLoad: () => assertFeatureEnabled("AFFILIATES_FEATURE"), + loader: async ({ context }) => { + // Check if user is already an affiliate (always fetch fresh data) + const { data: affiliateCheck } = await context.queryClient.fetchQuery({ + queryKey: ["affiliate", "check"], + queryFn: () => checkIfUserIsAffiliateFn(), + }); + + // Check feature flags + const customPaymentLinkEnabled = await context.queryClient.ensureQueryData({ + queryKey: ["featureFlag", "AFFILIATE_CUSTOM_PAYMENT_LINK"], + queryFn: () => + isFeatureEnabledForUserFn({ + data: { flagKey: "AFFILIATE_CUSTOM_PAYMENT_LINK" }, + }), + }); + + // Get commission rate and minimum payout from settings + const [commissionRate, minimumPayout] = await Promise.all([ + context.queryClient.ensureQueryData({ + queryKey: ["affiliateCommissionRate"], + queryFn: () => getPublicAffiliateCommissionRateFn(), + }), + context.queryClient.ensureQueryData({ + queryKey: ["affiliateMinimumPayout"], + queryFn: () => getPublicAffiliateMinimumPayoutFn(), + }), + ]); + + return { + isAffiliate: affiliateCheck.isAffiliate, + isOnboardingComplete: affiliateCheck.isOnboardingComplete, + paymentMethod: affiliateCheck.paymentMethod, + stripeAccountStatus: affiliateCheck.stripeAccountStatus, + hasStripeAccount: affiliateCheck.hasStripeAccount, + customPaymentLinkEnabled, + commissionRate, + minimumPayout, + }; + }, + component: AffiliateOnboarding, +}); + +function AffiliateOnboarding() { + const loaderData = Route.useLoaderData(); + const search = useSearch({ from: "/affiliate-onboarding" }); + const router = useRouter(); + const user = useAuth(); + const queryClient = useQueryClient(); + + // Determine initial step based on URL params and affiliate status + const getInitialStep = (): WizardStep => { + if (search.stripeComplete) return "complete"; // Return from Stripe = complete + + // If onboarding is complete, show complete step + if (loaderData.isOnboardingComplete) { + return "complete"; + } + + // If affiliate has Stripe account (even if not fully active), they completed Stripe setup + // Show "complete" step with status message + if (loaderData.hasStripeAccount && loaderData.paymentMethod === "stripe") { + return "complete"; + } + + // If affiliate exists but no payment configured, they need to select payment method + // Don't allow going back to terms if already registered + if (loaderData.isAffiliate) { + // Allow explicit step navigation except for "terms" (can't re-register) + if (search.step && search.step !== "terms") { + return search.step; + } + return "payment-method"; + } + + // New users: respect step param or start with terms + if (search.step) return search.step; + return "terms"; + }; + + const [currentStep, setCurrentStep] = useState(getInitialStep); + const [selectedMethod, setSelectedMethod] = useState( + search.method || null + ); + const [paymentLink, setPaymentLink] = useState(""); + const [termsOpen, setTermsOpen] = useState(false); + const [agreedToTerms, setAgreedToTerms] = useState(false); + + const form = useForm>({ + resolver: zodResolver(paymentLinkSchema), + defaultValues: { + paymentLink: "", + }, + }); + + // No auto-redirect - let users access onboarding to change settings if needed + + // Handle Stripe callback return + useEffect(() => { + if (search.stripeComplete && search.method) { + setSelectedMethod(search.method); + setCurrentStep("terms"); + } + }, [search.stripeComplete, search.method]); + + const registerMutation = useMutation({ + mutationFn: registerAffiliateFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["affiliate"] }); + // After registration, go to payment method selection + setCurrentStep("payment-method"); + }, + onError: (error) => { + toast.error("Registration Failed", { + description: error.message || "Failed to complete registration. Please try again.", + }); + }, + }); + + // Mutation to update payment method to link (must be before early returns!) + const updatePaymentMutation = useMutation({ + mutationFn: async (paymentLink: string) => { + const { updatePaymentMethodFn } = await import("~/fn/affiliates"); + return updatePaymentMethodFn({ + data: { paymentMethod: "link", paymentLink }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["affiliate"] }); + toast.success("Payment method updated!", { + description: "Your payment settings have been saved.", + }); + setCurrentStep("complete"); + }, + onError: (error) => { + toast.error("Failed to save payment link", { + description: error.message || "Please try again.", + }); + }, + }); + + // If not logged in, show login prompt + if (!user) { + return ( +
+

Join Our Affiliate Program

+

+ Please login to join our affiliate program +

+ + Login to Continue + +
+ ); + } + + const handleMethodSelect = (method: PaymentMethodType) => { + setSelectedMethod(method); + }; + + const handleContinueFromMethodStep = () => { + if (!selectedMethod) { + toast.error("Please select a payment method"); + return; + } + + if (selectedMethod === "stripe-express") { + document.cookie = `affiliate_onboarding=stripe-express; path=/; max-age=3600`; + window.location.href = "/api/connect/stripe"; + } else if (selectedMethod === "stripe-oauth") { + document.cookie = `affiliate_onboarding=stripe-oauth; path=/; max-age=3600`; + window.location.href = "/api/connect/stripe/oauth"; + } else { + // For link: go to payment-setup + setCurrentStep("payment-setup"); + } + }; + + const handlePaymentLinkSubmit = async (values: z.infer) => { + await updatePaymentMutation.mutateAsync(values.paymentLink); + }; + + const handleAcceptTerms = async () => { + if (!agreedToTerms) { + toast.error("Please agree to the terms of service"); + return; + } + + // Register affiliate with stripe as default (they'll configure payment next) + await registerMutation.mutateAsync({ + data: { + paymentMethod: "stripe", + agreedToTerms: true, + }, + }); + }; + + const handleGoToDashboard = () => { + router.navigate({ to: "/affiliate-dashboard" }); + }; + + // Steps - terms FIRST, then payment method + const steps = [ + { id: "terms", label: "Terms" }, + { id: "payment-method", label: "Payment Method" }, + { id: "payment-setup", label: "Setup" }, + { id: "complete", label: "Complete" }, + ]; + + const currentStepIndex = steps.findIndex((s) => s.id === currentStep); + + return ( +
+
+ {/* Progress indicator */} +
+
+ {steps.map((step, index) => ( +
+
+ {index < currentStepIndex ? ( + + ) : ( + index + 1 + )} +
+ {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+
+ {steps.map((step) => ( + + {step.label} + + ))} +
+
+ + + {/* Step 1: Payment Method Selection */} + {currentStep === "payment-method" && ( + + + + Choose Your Payment Method + + Select how you'd like to receive your affiliate payouts + + + + {/* Stripe Express */} + + + {/* Stripe OAuth */} + + + {/* Custom Link - only if feature enabled */} + {loaderData.customPaymentLinkEnabled && ( + + )} + + + + + + )} + + {/* Step 2: Payment Setup (only for custom link) */} + {currentStep === "payment-setup" && selectedMethod === "link" && ( + + + + Enter Your Payment Link + + We'll use this link to send your affiliate payouts + + + +
+ + ( + + Payment Link + + + + + Enter your PayPal.me, Venmo, or other payment link + + + + )} + /> + +
+ + +
+ + +
+
+
+ )} + + {/* Step 3: Terms Agreement */} + {currentStep === "terms" && ( + + + + Accept Terms of Service + + Please review and accept our affiliate program terms + + + +
+
+ + {loaderData.commissionRate}% Commission Rate +
+
+ + {AFFILIATE_CONFIG.COOKIE_DURATION_DAYS}-Day Cookie Duration +
+
+ + Automatic Stripe Payouts +
+ {loaderData.customPaymentLinkEnabled && ( +
+ + ${loaderData.minimumPayout / 100} minimum for custom payment links +
+ )} +
+ +
+ setAgreedToTerms(checked === true)} + /> + +
+ + +
+
+
+ )} + + {/* Step 4: Complete */} + {currentStep === "complete" && ( + + + + {loaderData.isOnboardingComplete ? ( + <> +
+ +
+

Welcome to the Team!

+

+ Your affiliate account is ready. Start sharing your unique link to earn commissions. +

+ + + ) : loaderData.hasStripeAccount && loaderData.stripeAccountStatus === "onboarding" ? ( + <> +
+ +
+

Almost There!

+

+ Your Stripe account setup is in progress. Please complete the verification to enable payouts. +

+

+ Status: Onboarding +

+
+ +
+ + ) : loaderData.hasStripeAccount && loaderData.stripeAccountStatus === "restricted" ? ( + <> +
+ +
+

Action Required

+

+ Your Stripe account has restrictions. Please complete the required verifications. +

+

+ Status: Restricted +

+
+ +
+ + ) : ( + <> +
+ +
+

Setup Complete!

+

+ Your affiliate account has been configured. +

+ + + )} +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/routes/affiliates.tsx b/src/routes/affiliates.tsx index 7faa10f7..1bb5e1b6 100644 --- a/src/routes/affiliates.tsx +++ b/src/routes/affiliates.tsx @@ -1,34 +1,11 @@ -import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { AFFILIATE_CONFIG } from "~/config"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { assertFeatureEnabled } from "~/lib/feature-flags"; -import { Button, buttonVariants } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Checkbox } from "~/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; -import { toast } from "sonner"; +import { buttonVariants } from "~/components/ui/button"; import { useAuth } from "~/hooks/use-auth"; -import { registerAffiliateFn, checkIfUserIsAffiliateFn } from "~/fn/affiliates"; -import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; +import { checkIfUserIsAffiliateFn } from "~/fn/affiliates"; +import { getPublicAffiliateCommissionRateFn, getPricingSettingsFn } from "~/fn/app-settings"; +import { AFFILIATE_CONFIG } from "~/config"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { DollarSign, Users, @@ -36,167 +13,66 @@ import { Clock, Shield, Award, - CheckCircle, ArrowRight, Zap, BarChart3, } from "lucide-react"; -import { cn } from "~/lib/utils"; - -const affiliateFormSchema = z.object({ - paymentLink: z.url("Please provide a valid URL"), - agreedToTerms: z.boolean().refine((val) => val === true, { - message: "You must agree to the terms of service", - }), -}); - -type AffiliateFormValues = z.infer; export const Route = createFileRoute("/affiliates")({ beforeLoad: () => assertFeatureEnabled("AFFILIATES_FEATURE"), loader: async ({ context }) => { - const isAffiliate = await context.queryClient.ensureQueryData({ - queryKey: ["user", "isAffiliate"], - queryFn: () => checkIfUserIsAffiliateFn(), - }); - return isAffiliate; + const [affiliateCheckResponse, commissionRate, pricingSettings] = await Promise.all([ + context.queryClient.fetchQuery({ + queryKey: ["affiliate", "check"], + queryFn: () => checkIfUserIsAffiliateFn(), + }), + context.queryClient.ensureQueryData({ + queryKey: ["affiliateCommissionRate"], + queryFn: () => getPublicAffiliateCommissionRateFn(), + }), + context.queryClient.ensureQueryData({ + queryKey: ["pricingSettings"], + queryFn: () => getPricingSettingsFn(), + }), + ]); + + // Calculate max per-sale commission (originalPrice in dollars, commissionRate in percent) + const maxCommissionPerSale = Math.floor(pricingSettings.originalPrice * (commissionRate / 100)); + const affiliateCheck = affiliateCheckResponse.data; + + return { + isAffiliate: affiliateCheck.isAffiliate, + isOnboardingComplete: affiliateCheck.isOnboardingComplete, + commissionRate, + maxCommissionPerSale, + }; }, component: AffiliatesPage, }); function AffiliatesPage() { const user = useAuth(); - const router = useRouter(); - const [termsOpen, setTermsOpen] = useState(false); - const { data: affiliateStatus } = useSuspenseQuery({ - queryKey: ["user", "isAffiliate"], - queryFn: () => checkIfUserIsAffiliateFn(), - }); - - const form = useForm({ - resolver: zodResolver(affiliateFormSchema), - defaultValues: { - paymentLink: "", - agreedToTerms: false, - }, - }); - - const registerMutation = useMutation({ - mutationFn: registerAffiliateFn, - onSuccess: () => { - toast.success("Welcome to the Affiliate Program!", { - description: - "You can now access your affiliate dashboard to get your unique link.", - }); - router.navigate({ to: "/affiliate-dashboard" }); - }, - onError: (error) => { - toast.error("Registration Failed", { - description: - error.message || - "Failed to register as an affiliate. Please try again.", - }); - }, - }); - - const onSubmit = async (values: AffiliateFormValues) => { - await registerMutation.mutateAsync({ data: values }); - }; + const loaderData = Route.useLoaderData(); + const { isAffiliate, isOnboardingComplete = false, commissionRate, maxCommissionPerSale } = loaderData; if (!user) { return ( -
- {/* Hero background matching the app's main theme */} -
-
- - {/* Circuit pattern overlay */} -
-
-
- - {/* Floating elements for ambiance */} -
-
-
-
-
-
-
- - {/* Content */} -
-
- {/* Badge */} -
- - Earn {AFFILIATE_CONFIG.COMMISSION_RATE}% Commission -
- - {/* Hero title with gradient text */} -

- Join Our Affiliate Program -

- - {/* Subtitle */} -

- Partner with us and earn generous commissions -

-

- Join our exclusive affiliate program and start earning $60 per - sale by sharing our AI coding mastery course with your audience. -

- - {/* Benefits preview */} -
-
- -

{AFFILIATE_CONFIG.COMMISSION_RATE}% Commission

-

$60 per sale

-
-
- -

30-Day Cookie

-

- Long attribution window -

-
-
- -

Real-Time Tracking

-

- Monitor your earnings -

-
-
- - {/* Call to action */} -
- - - Login to Join Program - - -

- Sign in with Google to access the affiliate program -

-
-
-
- - {/* Bottom gradient divider */} -
+
+

Join Our Affiliate Program

+

+ Please login to join our affiliate program +

+ + Login to Continue +
); } - if (affiliateStatus?.isAffiliate) { + if (isAffiliate && isOnboardingComplete) { return (

@@ -216,6 +92,26 @@ function AffiliatesPage() { ); } + if (isAffiliate && !isOnboardingComplete) { + return ( +
+

+ Complete Your Setup +

+

+ You've started the affiliate registration. Complete your payment setup to start earning. +

+ + Continue Setup + + +
+ ); + } + return (
{/* Background gradient */} @@ -235,7 +131,7 @@ function AffiliatesPage() {
- Earn {AFFILIATE_CONFIG.COMMISSION_RATE}% Commission + Earn {commissionRate}% Commission

@@ -254,19 +150,19 @@ function AffiliatesPage() {
-

{AFFILIATE_CONFIG.COMMISSION_RATE}% Commission

+

{commissionRate}% Commission

- Earn $60 for every sale you refer. One of the highest commission + Earn generous commissions for every sale you refer. One of the highest commission rates in the industry.

-

30-Day Cookie

+

{AFFILIATE_CONFIG.COOKIE_DURATION_DAYS}-Day Cookie

Long attribution window ensures you get credit for purchases - made within 30 days. + made within {AFFILIATE_CONFIG.COOKIE_DURATION_DAYS} days.

@@ -360,266 +256,53 @@ function AffiliatesPage() {

- {/* Registration Form */} + {/* Join CTA */}
-
-

- Join the Program +
+

+ Ready to Start Earning?

+

+ Join our affiliate program in just a few minutes and start earning {commissionRate}% commission on every referral. +

+ + + Become an Affiliate + + +
+

-
- - ( - - Payment Link - - - - - Enter your PayPal, Venmo, or other payment link where - you'd like to receive affiliate payouts. - - - - )} - /> - - ( - - - - -
- - I agree to the{" "} - - - - - -
- {/* Decorative background elements */} -
-
-
- -
- - {/* Badge */} -
- - Legal Agreement -
- - - Affiliate Program Terms of Service - - -

- Please review these terms carefully - before joining our affiliate program -

-
- -
-
-
-
- - 1 - -
-

- Commission Structure -

-
-

- Affiliates earn {AFFILIATE_CONFIG.COMMISSION_RATE}% commission on all - referred sales. Commissions are - calculated based on the net sale price - after any discounts. -

-
-
-
-
- - 2 - -
-

- Payment Terms -

-
-

- Payments are processed monthly with a - minimum payout threshold of $50. - Payments are made via the payment link - provided during registration. -

-
-
-
-
- - 3 - -
-

- Cookie Duration -

-
-

- Affiliate links have a 30-day cookie - duration. You will receive credit for - any purchases made within 30 days of a - user clicking your affiliate link. -

-
-
-
-
- - 4 - -
-

- Prohibited Activities -

-
-

- The following activities are strictly - prohibited: -

-
    -
  • Spam or unsolicited emails
  • -
  • - Misleading or false advertising -
  • -
  • - Self-referrals or fraudulent - purchases -
  • -
  • - Trademark or brand misrepresentation -
  • -
  • - Paid search advertising on - trademarked terms -
  • -
-
-
-
-
- - 5 - -
-

- Termination -

-
-

- We reserve the right to terminate - affiliate accounts that violate these - terms or engage in fraudulent - activity. Pending commissions may be - forfeited in cases of violation. -

-
-
-
-
- - 6 - -
-

- Modifications -

-
-

- We may modify these terms at any time. - Continued participation in the program - constitutes acceptance of any - modifications. -

-
-
-
-
- - 7 - -
-

- Liability -

-
-

- We are not liable for indirect, - special, or consequential damages - arising from your participation in the - affiliate program. -

-
-
-
-
-
-
-
- -
-
- )} - /> - - - - + {/* Success Metrics */} +
+

+ Why Partners Love Our Program +

+
+
+
+ + 12% +
+

Average conversion rate

+
+
+
+ + Up to ${maxCommissionPerSale} +
+

Per sale commission

+
+
+
+ + 98% +
+

Customer satisfaction

+
diff --git a/src/routes/api/connect/stripe/callback/index.ts b/src/routes/api/connect/stripe/callback/index.ts new file mode 100644 index 00000000..078d31b4 --- /dev/null +++ b/src/routes/api/connect/stripe/callback/index.ts @@ -0,0 +1,133 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getCookie, deleteCookie } from "@tanstack/react-start/server"; +import { stripe } from "~/lib/stripe"; +import { assertAuthenticated } from "~/utils/session"; +import { + getAffiliateByUserId, + updateAffiliateStripeAccount, +} from "~/data-access/affiliates"; +import { determineStripeAccountStatus } from "~/utils/stripe-status"; +import { timingSafeStringEqual } from "~/utils/crypto"; + +const AFTER_CONNECT_URL = "/affiliate-dashboard"; +const ONBOARDING_COMPLETE_URL = "/affiliate-onboarding?step=complete"; + +/** + * Stripe Connect OAuth callback route. + * + * This route handles the return flow after a user completes (or exits) the + * Stripe Connect onboarding process. + * + * Flow: + * 1. Validates the CSRF state token against the stored cookie + * 2. Clears the state and affiliate ID cookies + * 3. Authenticates the user and retrieves their affiliate record + * 4. Retrieves the Stripe account status from Stripe API + * 5. Determines the account status (active/pending/restricted/onboarding) + * 6. Updates the affiliate record in the database + * 7. Redirects to the affiliate dashboard + * + * Security: + * - CSRF validation via state parameter comparison + * - Double verification of affiliate ID + * - Re-authentication required + * + * @route GET /api/connect/stripe/callback + * @requires authentication + * @redirects /affiliate-dashboard + */ +export const Route = createFileRoute("/api/connect/stripe/callback/")({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url); + const state = url.searchParams.get("state"); + const storedState = getCookie("stripe_connect_state") ?? null; + const storedAffiliateId = getCookie("stripe_connect_affiliate_id") ?? null; + const onboardingInProgress = getCookie("affiliate_onboarding") ?? null; + + // Validate CSRF state token using timing-safe comparison + // Clear cookies on validation failure to prevent reuse + if (!timingSafeStringEqual(state, storedState)) { + deleteCookie("stripe_connect_state"); + deleteCookie("stripe_connect_affiliate_id"); + if (onboardingInProgress) { + deleteCookie("affiliate_onboarding"); + } + return new Response("Invalid state parameter", { status: 400 }); + } + + // Helper to clear all OAuth cookies + const clearOAuthCookies = () => { + deleteCookie("stripe_connect_state"); + deleteCookie("stripe_connect_affiliate_id"); + if (onboardingInProgress) { + deleteCookie("affiliate_onboarding"); + } + }; + + try { + // Require authentication + const user = await assertAuthenticated(); + + // Get affiliate record for this user + const affiliate = await getAffiliateByUserId(user.id); + + if (!affiliate) { + // Don't clear cookies - allow retry + return new Response("Affiliate account not found", { status: 404 }); + } + + // Verify affiliate ID matches (extra security) + if (storedAffiliateId && String(affiliate.id) !== storedAffiliateId) { + // Security violation - clear cookies to prevent reuse + clearOAuthCookies(); + return new Response("Affiliate mismatch", { status: 403 }); + } + + if (!affiliate.stripeConnectAccountId) { + // Don't clear cookies - allow retry + return new Response("Stripe Connect account not found", { status: 404 }); + } + + // Retrieve account status from Stripe + const account = await stripe.accounts.retrieve( + affiliate.stripeConnectAccountId + ); + + // Determine account status based on Stripe account state + const stripeAccountStatus = determineStripeAccountStatus(account); + + // Update database with account status + await updateAffiliateStripeAccount(affiliate.id, { + stripeAccountStatus, + stripeChargesEnabled: account.charges_enabled ?? false, + stripePayoutsEnabled: account.payouts_enabled ?? false, + stripeDetailsSubmitted: account.details_submitted ?? false, + stripeAccountType: account.type ?? null, + lastStripeSync: new Date(), + }); + + // Clear cookies only after successful completion + clearOAuthCookies(); + + // Redirect to onboarding complete step if coming from onboarding flow + // Otherwise go directly to dashboard + const redirectUrl = onboardingInProgress + ? ONBOARDING_COMPLETE_URL + : AFTER_CONNECT_URL; + return new Response(null, { + status: 302, + headers: { Location: redirectUrl }, + }); + } catch (error) { + console.error("Stripe Connect callback error:", error); + // Don't clear cookies on transient errors - allow retry + return new Response("Failed to complete Stripe Connect onboarding", { + status: 500, + }); + } + }, + }, + }, +}); diff --git a/src/routes/api/connect/stripe/index.ts b/src/routes/api/connect/stripe/index.ts new file mode 100644 index 00000000..112bc0dc --- /dev/null +++ b/src/routes/api/connect/stripe/index.ts @@ -0,0 +1,112 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { stripe } from "~/lib/stripe"; +import { assertAuthenticated } from "~/utils/session"; +import { + getAffiliateByUserId, + updateAffiliateStripeAccount, + checkAndRecordConnectAttempt, +} from "~/data-access/affiliates"; +import { env } from "~/utils/env"; +import { StripeAccountStatus } from "~/utils/stripe-status"; +import { generateCsrfState } from "~/utils/crypto"; + +const MAX_COOKIE_AGE_SECONDS = 60 * 10; // 10 minutes + +function buildCookie(name: string, value: string, maxAge: number): string { + const secure = env.NODE_ENV === "production" ? "; Secure" : ""; + return `${name}=${value}; Path=/; HttpOnly; SameSite=lax; Max-Age=${maxAge}${secure}`; +} + +/** + * Stripe Connect OAuth initiation route. + * + * This route initiates the Stripe Connect onboarding flow for affiliates who want + * to receive automatic payouts via Stripe. + * + * Flow: + * 1. Authenticates the user and verifies they are an affiliate + * 2. Generates a CSRF state token and stores it in HTTP-only cookies + * 3. Creates a Stripe Express account if one doesn't exist + * 4. Generates a Stripe account onboarding link + * 5. Redirects the user to Stripe's hosted onboarding flow + * + * Security: + * - CSRF protection via state parameter stored in HTTP-only cookie (10 min expiration) + * - Affiliate ID stored in cookie for validation on callback + * + * @route GET /api/connect/stripe + * @requires authentication + * @redirects Stripe hosted onboarding page + */ +export const Route = createFileRoute("/api/connect/stripe/")({ + server: { + handlers: { + GET: async () => { + // Require authentication + const user = await assertAuthenticated(); + + // Get affiliate record for this user + const affiliate = await getAffiliateByUserId(user.id); + + if (!affiliate) { + return new Response("Affiliate account not found", { status: 404 }); + } + + // Check rate limiting and record attempt atomically (3 attempts per hour) + const allowed = await checkAndRecordConnectAttempt(affiliate.id); + if (!allowed) { + return new Response( + "Too many Stripe Connect attempts. Please try again later.", + { status: 429 } + ); + } + + // Generate CSRF state token + const state = generateCsrfState(); + + try { + let accountId = affiliate.stripeConnectAccountId; + + // Create Stripe Express account if not exists + if (!accountId) { + const account = await stripe.accounts.create({ + type: "express", + email: user.email ?? undefined, + metadata: { + affiliateId: String(affiliate.id), + userId: String(user.id), + }, + }); + accountId = account.id; + + // Update affiliate with the new account ID + await updateAffiliateStripeAccount(affiliate.id, { + stripeConnectAccountId: accountId, + stripeAccountStatus: StripeAccountStatus.ONBOARDING, + }); + } + + // Generate account onboarding link + const accountLink = await stripe.accountLinks.create({ + account: accountId, + refresh_url: `${env.HOST_NAME}/api/connect/stripe/refresh?state=${state}`, + return_url: `${env.HOST_NAME}/api/connect/stripe/callback?state=${state}`, + type: "account_onboarding", + }); + + // Return redirect with cookies in headers (avoids TanStack Start immutable headers bug) + const headers = new Headers(); + headers.set("Location", accountLink.url); + headers.append("Set-Cookie", buildCookie("stripe_connect_state", state, MAX_COOKIE_AGE_SECONDS)); + headers.append("Set-Cookie", buildCookie("stripe_connect_affiliate_id", String(affiliate.id), MAX_COOKIE_AGE_SECONDS)); + return new Response(null, { status: 302, headers }); + } catch (error) { + console.error("Stripe Connect account creation error:", error); + return new Response("Failed to initiate Stripe Connect onboarding", { + status: 500, + }); + } + }, + }, + }, +}); diff --git a/src/routes/api/connect/stripe/oauth/callback/index.ts b/src/routes/api/connect/stripe/oauth/callback/index.ts new file mode 100644 index 00000000..dfb9d81b --- /dev/null +++ b/src/routes/api/connect/stripe/oauth/callback/index.ts @@ -0,0 +1,150 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getCookie, deleteCookie } from "@tanstack/react-start/server"; +import { stripe } from "~/lib/stripe"; +import { assertAuthenticated } from "~/utils/session"; +import { + getAffiliateByUserId, + updateAffiliateStripeAccount, +} from "~/data-access/affiliates"; +import { determineStripeAccountStatus } from "~/utils/stripe-status"; +import { timingSafeStringEqual } from "~/utils/crypto"; + +const AFTER_CONNECT_URL = "/affiliate-dashboard"; +const ONBOARDING_COMPLETE_URL = "/affiliate-onboarding?step=complete"; + +/** + * Stripe OAuth callback route for connecting existing Stripe accounts. + * + * This route handles the return flow after a user authorizes their existing + * Stripe account for connection. + * + * Flow: + * 1. Validates the CSRF state token against the stored cookie + * 2. Exchanges the authorization code for account info + * 3. Stores the connected account ID + * 4. Retrieves and updates the account status + * 5. Redirects to the affiliate dashboard + * + * @route GET /api/connect/stripe/oauth/callback + * @requires authentication + * @redirects /affiliate-dashboard + */ +export const Route = createFileRoute("/api/connect/stripe/oauth/callback/")({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + const storedState = getCookie("stripe_oauth_state") ?? null; + const storedAffiliateId = getCookie("stripe_oauth_affiliate_id") ?? null; + const onboardingInProgress = getCookie("affiliate_onboarding") ?? null; + + // Handle OAuth errors (user denied, etc.) + if (error) { + console.error("Stripe OAuth error:", error, errorDescription); + // Clear cookies on error + deleteCookie("stripe_oauth_state"); + deleteCookie("stripe_oauth_affiliate_id"); + deleteCookie("stripe_oauth_type"); + if (onboardingInProgress) { + deleteCookie("affiliate_onboarding"); + } + return new Response(null, { + status: 302, + headers: { Location: `${AFTER_CONNECT_URL}?error=oauth_denied` }, + }); + } + + // Validate CSRF state token using timing-safe comparison + // Clear cookies on validation failure to prevent reuse + if (!timingSafeStringEqual(state, storedState)) { + deleteCookie("stripe_oauth_state"); + deleteCookie("stripe_oauth_affiliate_id"); + deleteCookie("stripe_oauth_type"); + if (onboardingInProgress) { + deleteCookie("affiliate_onboarding"); + } + return new Response("Invalid state parameter", { status: 400 }); + } + + if (!code) { + return new Response("Missing authorization code", { status: 400 }); + } + + // Clear cookies AFTER successful validation + deleteCookie("stripe_oauth_state"); + deleteCookie("stripe_oauth_affiliate_id"); + deleteCookie("stripe_oauth_type"); + if (onboardingInProgress) { + deleteCookie("affiliate_onboarding"); + } + + try { + // Require authentication + const user = await assertAuthenticated(); + + // Get affiliate record for this user + const affiliate = await getAffiliateByUserId(user.id); + + if (!affiliate) { + return new Response("Affiliate account not found", { status: 404 }); + } + + // Verify affiliate ID matches (extra security) + if (storedAffiliateId && String(affiliate.id) !== storedAffiliateId) { + return new Response("Affiliate mismatch", { status: 403 }); + } + + // Exchange authorization code for connected account ID + const response = await stripe.oauth.token({ + grant_type: "authorization_code", + code, + }); + + const connectedAccountId = response.stripe_user_id; + + if (!connectedAccountId) { + console.error("No stripe_user_id in OAuth response"); + return new Response("Failed to connect Stripe account", { status: 500 }); + } + + // Retrieve account status from Stripe + const account = await stripe.accounts.retrieve(connectedAccountId); + + // Determine account status based on Stripe account state + const stripeAccountStatus = determineStripeAccountStatus(account); + + // Update database with connected account + await updateAffiliateStripeAccount(affiliate.id, { + stripeConnectAccountId: connectedAccountId, + stripeAccountStatus, + stripeChargesEnabled: account.charges_enabled ?? false, + stripePayoutsEnabled: account.payouts_enabled ?? false, + stripeDetailsSubmitted: account.details_submitted ?? false, + stripeAccountType: account.type ?? null, + lastStripeSync: new Date(), + }); + + // Redirect to onboarding complete step if coming from onboarding flow + // Otherwise go directly to dashboard + const redirectUrl = onboardingInProgress + ? ONBOARDING_COMPLETE_URL + : `${AFTER_CONNECT_URL}?connected=true`; + return new Response(null, { + status: 302, + headers: { Location: redirectUrl }, + }); + } catch (err) { + console.error("Stripe OAuth callback error:", err); + return new Response("Failed to complete Stripe account connection", { + status: 500, + }); + } + }, + }, + }, +}); diff --git a/src/routes/api/connect/stripe/oauth/index.ts b/src/routes/api/connect/stripe/oauth/index.ts new file mode 100644 index 00000000..514fbdef --- /dev/null +++ b/src/routes/api/connect/stripe/oauth/index.ts @@ -0,0 +1,96 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { assertAuthenticated } from "~/utils/session"; +import { + getAffiliateByUserId, + checkAndRecordConnectAttempt, +} from "~/data-access/affiliates"; +import { env } from "~/utils/env"; +import { generateCsrfState } from "~/utils/crypto"; + +const MAX_COOKIE_AGE_SECONDS = 60 * 10; // 10 minutes + +function buildCookie(name: string, value: string, maxAge: number): string { + const secure = env.NODE_ENV === "production" ? "; Secure" : ""; + return `${name}=${value}; Path=/; HttpOnly; SameSite=lax; Max-Age=${maxAge}${secure}`; +} + +/** + * Stripe Connect OAuth initiation route for connecting EXISTING Stripe accounts. + * + * This route initiates the Stripe OAuth flow for affiliates who already have + * a Stripe account and want to connect it for payouts. + * + * Flow: + * 1. Authenticates the user and verifies they are an affiliate + * 2. Generates a CSRF state token and stores it in HTTP-only cookies + * 3. Redirects to Stripe's OAuth authorization page + * 4. User authorizes access on Stripe + * 5. Stripe redirects to callback with authorization code + * + * Security: + * - CSRF protection via state parameter stored in HTTP-only cookie + * - Affiliate ID stored in cookie for validation on callback + * + * @route GET /api/connect/stripe/oauth + * @requires authentication + * @redirects Stripe OAuth authorization page + */ +export const Route = createFileRoute("/api/connect/stripe/oauth/")({ + server: { + handlers: { + GET: async () => { + // Require authentication + const user = await assertAuthenticated(); + + // Get affiliate record for this user + const affiliate = await getAffiliateByUserId(user.id); + + if (!affiliate) { + return new Response("Affiliate account not found", { status: 404 }); + } + + // Don't allow if already connected + if (affiliate.stripeConnectAccountId && affiliate.stripePayoutsEnabled) { + return new Response("Stripe account already connected", { status: 400 }); + } + + // Check rate limiting and record attempt atomically (3 attempts per hour) + const allowed = await checkAndRecordConnectAttempt(affiliate.id); + if (!allowed) { + return new Response( + "Too many Stripe Connect attempts. Please try again later.", + { status: 429 } + ); + } + + // Generate CSRF state token + const state = generateCsrfState(); + + // Build Stripe OAuth authorization URL + // Uses Standard account type for connecting existing accounts + const stripeClientId = env.STRIPE_CLIENT_ID; + if (!stripeClientId) { + console.error("STRIPE_CLIENT_ID not configured"); + return new Response("Stripe OAuth not configured", { status: 500 }); + } + + const redirectUri = `${env.HOST_NAME}/api/connect/stripe/oauth/callback`; + const oauthUrl = new URL("https://connect.stripe.com/oauth/authorize"); + oauthUrl.searchParams.set("response_type", "code"); + oauthUrl.searchParams.set("client_id", stripeClientId); + oauthUrl.searchParams.set("scope", "read_write"); + oauthUrl.searchParams.set("redirect_uri", redirectUri); + oauthUrl.searchParams.set("state", state); + + // Return redirect with cookies in headers + const headers = new Headers(); + headers.set("Location", oauthUrl.toString()); + headers.append("Set-Cookie", buildCookie("stripe_oauth_state", state, MAX_COOKIE_AGE_SECONDS)); + headers.append("Set-Cookie", buildCookie("stripe_oauth_affiliate_id", String(affiliate.id), MAX_COOKIE_AGE_SECONDS)); + headers.append("Set-Cookie", buildCookie("stripe_oauth_type", "standard", MAX_COOKIE_AGE_SECONDS)); + + return new Response(null, { status: 302, headers }); + }, + }, + }, +}); diff --git a/src/routes/api/connect/stripe/refresh/index.ts b/src/routes/api/connect/stripe/refresh/index.ts new file mode 100644 index 00000000..e88e52db --- /dev/null +++ b/src/routes/api/connect/stripe/refresh/index.ts @@ -0,0 +1,110 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { setCookie, getCookie, deleteCookie } from "@tanstack/react-start/server"; +import { stripe } from "~/lib/stripe"; +import { assertAuthenticated } from "~/utils/session"; +import { getAffiliateByUserId } from "~/data-access/affiliates"; +import { env } from "~/utils/env"; +import { generateCsrfState, timingSafeStringEqual } from "~/utils/crypto"; + +const MAX_COOKIE_AGE_SECONDS = 60 * 10; // 10 minutes + +/** + * Stripe Connect OAuth refresh route. + * + * This route handles the case where a user exits the Stripe onboarding flow + * before completing it. It regenerates a new onboarding link to allow them + * to continue where they left off. + * + * Flow: + * 1. Validates the CSRF state token (from the original initiation) + * 2. Generates a new CSRF state token + * 3. Stores new state and affiliate ID in cookies + * 4. Generates a fresh Stripe account onboarding link + * 5. Redirects back to Stripe onboarding + * + * @route GET /api/connect/stripe/refresh + * @requires authentication + * @redirects Stripe hosted onboarding page (retry) + */ +export const Route = createFileRoute("/api/connect/stripe/refresh/")({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url); + const incomingState = url.searchParams.get("state"); + const storedState = getCookie("stripe_connect_state") ?? null; + + // Validate CSRF state token (from the original request) + if (!incomingState || !storedState || !timingSafeStringEqual(incomingState, storedState)) { + // If state validation fails, redirect to start fresh + return new Response(null, { + status: 302, + headers: { Location: "/api/connect/stripe" }, + }); + } + + // Clear old cookies + deleteCookie("stripe_connect_state"); + deleteCookie("stripe_connect_affiliate_id"); + + try { + // Require authentication + const user = await assertAuthenticated(); + + // Get affiliate record for this user + const affiliate = await getAffiliateByUserId(user.id); + + if (!affiliate) { + return new Response("Affiliate account not found", { status: 404 }); + } + + if (!affiliate.stripeConnectAccountId) { + // No Stripe account exists, redirect to initiation + return new Response(null, { + status: 302, + headers: { Location: "/api/connect/stripe" }, + }); + } + + // Generate new CSRF state token for the retry + const newState = generateCsrfState(); + + // Store new state in HTTP-only cookie + // Note: sameSite "lax" is required for OAuth flows - "strict" would block + // cookies on the redirect back from Stripe (cross-site navigation) + setCookie("stripe_connect_state", newState, { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + maxAge: MAX_COOKIE_AGE_SECONDS, + }); + + // Store affiliate ID in cookie for callback + setCookie("stripe_connect_affiliate_id", String(affiliate.id), { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + maxAge: MAX_COOKIE_AGE_SECONDS, + }); + + // Re-generate account onboarding link for the existing account + const accountLink = await stripe.accountLinks.create({ + account: affiliate.stripeConnectAccountId, + refresh_url: `${env.HOST_NAME}/api/connect/stripe/refresh?state=${newState}`, + return_url: `${env.HOST_NAME}/api/connect/stripe/callback?state=${newState}`, + type: "account_onboarding", + }); + + return Response.redirect(accountLink.url); + } catch (error) { + console.error("Stripe Connect refresh error:", error); + return new Response("Failed to refresh Stripe Connect onboarding", { + status: 500, + }); + } + }, + }, + }, +}); diff --git a/src/routes/api/login/google/callback/index.ts b/src/routes/api/login/google/callback/index.ts index 901bb902..872f751c 100644 --- a/src/routes/api/login/google/callback/index.ts +++ b/src/routes/api/login/google/callback/index.ts @@ -65,9 +65,7 @@ export const Route = createFileRoute("/api/login/google/callback/")({ const googleUser: GoogleUser = await response.json(); - const existingAccount = await getAccountByGoogleIdUseCase( - googleUser.sub - ); + const existingAccount = await getAccountByGoogleIdUseCase(googleUser.sub); if (existingAccount) { await setSession(existingAccount.userId); @@ -90,9 +88,7 @@ export const Route = createFileRoute("/api/login/google/callback/")({ throw e; } console.error(e); - // the specific error message depends on the provider if (e instanceof OAuth2RequestError) { - // invalid code return new Response(null, { status: 400 }); } return new Response(null, { status: 500 }); diff --git a/src/routes/api/stripe/webhook.ts b/src/routes/api/stripe/webhook.ts index 13ac1f06..1f78072e 100644 --- a/src/routes/api/stripe/webhook.ts +++ b/src/routes/api/stripe/webhook.ts @@ -1,172 +1,415 @@ import { createFileRoute } from "@tanstack/react-router"; +import type Stripe from "stripe"; import { stripe } from "~/lib/stripe"; import { updateUserToPremiumUseCase } from "~/use-cases/users"; -import { processAffiliateReferralUseCase } from "~/use-cases/affiliates"; -import { env } from "~/utils/env"; +import { + processAffiliateReferralUseCase, + syncStripeAccountStatusUseCase, +} from "~/use-cases/affiliates"; +import { + getAffiliateByCode, + getAffiliateByStripeSession, + getPayoutByStripeTransferId, + getAffiliateByStripeAccountIdWithUserEmail, + updateAffiliatePayoutError, +} from "~/data-access/affiliates"; import { trackAnalyticsEvent } from "~/data-access/analytics"; +import { sendAffiliatePayoutFailedEmail } from "~/utils/email"; +import { logger } from "~/utils/logger"; +import { env } from "~/utils/env"; + +// Map Stripe error codes/messages to user-friendly messages +function getUserFriendlyPayoutError(stripeError: string | undefined): string { + if (!stripeError) { + return "A payout error occurred. Please check your Stripe dashboard or contact support."; + } + + const lowerError = stripeError.toLowerCase(); + + if (lowerError.includes("insufficient_funds") || lowerError.includes("insufficient funds")) { + return "Your connected account has insufficient funds"; + } + if (lowerError.includes("account_restricted") || lowerError.includes("account restricted")) { + return "Your Stripe account has restrictions that need to be resolved"; + } + if (lowerError.includes("invalid_account") || lowerError.includes("invalid account")) { + return "There's an issue with your connected account"; + } -const webhookSecret = env.STRIPE_WEBHOOK_SECRET!; + return "A payout error occurred. Please check your Stripe dashboard or contact support."; +} + +const webhookSecret = env.STRIPE_WEBHOOK_SECRET; export const Route = createFileRoute("/api/stripe/webhook")({ server: { handlers: { POST: async ({ request }) => { - const sig = request.headers.get("stripe-signature"); - const payload = await request.text(); - - // Log webhook receipt for debugging - const userAgent = request.headers.get("user-agent") || ""; - const isStripeCLI = userAgent.includes("Stripe/1."); - console.log("Webhook received:", { - hasSignature: !!sig, - payloadLength: payload.length, - webhookSecretSet: !!webhookSecret, - webhookSecretPrefix: webhookSecret?.substring(0, 10) + "...", - source: isStripeCLI ? "Stripe CLI" : "Stripe Dashboard/API", - userAgent: userAgent.substring(0, 50), - url: request.url, + const sig = request.headers.get("stripe-signature"); + + if (!sig) { + logger.error("Webhook Error: Missing stripe-signature header", { fn: "stripe-webhook" }); + return new Response( + JSON.stringify({ error: "Missing stripe-signature header" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // Validate webhook secret is configured + if (!webhookSecret) { + logger.error("Webhook Error: STRIPE_WEBHOOK_SECRET not configured", { fn: "stripe-webhook" }); + return new Response( + JSON.stringify({ error: "Webhook secret not configured" }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const payload = await request.text(); + + // Verify webhook signature - return 400 on failure so Stripe doesn't retry with invalid signatures + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent( + payload, + sig, + webhookSecret + ); + } catch (signatureError) { + logger.error("Webhook signature verification failed", { + fn: "stripe-webhook", + error: signatureError instanceof Error ? signatureError.message : String(signatureError), }); + return new Response( + JSON.stringify({ error: "Invalid signature" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } - if (!sig) { - console.error("Missing stripe-signature header"); - return new Response( - JSON.stringify({ error: "Missing stripe-signature header" }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - } - ); - } + // Process the event - return 200 on errors to prevent Stripe from retrying + // (we've already verified the webhook, so retrying won't help with processing errors) + try { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object; - if (!webhookSecret) { - console.error( - "STRIPE_WEBHOOK_SECRET environment variable is not set" - ); - return new Response( - JSON.stringify({ error: "Webhook secret not configured" }), - { - status: 500, - headers: { "Content-Type": "application/json" }, + // Idempotency check: skip if this session was already processed + const existingReferral = await getAffiliateByStripeSession(session.id); + if (existingReferral) { + logger.info("Skipping already processed checkout session", { + fn: "stripe-webhook", + sessionId: session.id, + existingReferralId: existingReferral.referral.id, + }); + break; } - ); - } - try { - const event = stripe.webhooks.constructEvent( - payload, - sig, - webhookSecret - ); - - console.log("Webhook event constructed successfully:", event.type); - - switch (event.type) { - case "checkout.session.completed": { - const session = event.data.object; - const userId = session.metadata?.userId; - const affiliateCode = session.metadata?.affiliateCode; - const analyticsSessionId = session.metadata?.analyticsSessionId; - const gclid = session.metadata?.gclid; - - if (userId) { - await updateUserToPremiumUseCase(parseInt(userId)); - console.log(`Updated user ${userId} to premium status`); - - // Track purchase completion in analytics - if (analyticsSessionId) { - try { - await trackAnalyticsEvent({ - sessionId: analyticsSessionId, - eventType: "purchase_completed", - pagePath: "/success", - metadata: { - userId: parseInt(userId), - amount: session.amount_total, - stripeSessionId: session.id, - affiliateCode, - gclid, - }, + const userId = session.metadata?.userId; + const affiliateCode = session.metadata?.affiliateCode; + const analyticsSessionId = session.metadata?.analyticsSessionId; + + if (userId) { + await updateUserToPremiumUseCase(parseInt(userId)); + logger.info("Updated user to premium status", { fn: "stripe-webhook", userId }); + + // Track purchase completion in analytics + if (analyticsSessionId) { + try { + await trackAnalyticsEvent({ + sessionId: analyticsSessionId, + userId: parseInt(userId), + eventType: 'purchase_completed', + pagePath: '/success', + metadata: { + amount: session.amount_total, + stripeSessionId: session.id, + affiliateCode, + }, + }); + logger.info("Tracked purchase completion", { fn: "stripe-webhook", analyticsSessionId }); + } catch (error) { + logger.error("Failed to track purchase completion", { + fn: "stripe-webhook", + error: error instanceof Error ? error.message : String(error), + }); + // Don't fail the webhook for analytics errors + } + } + + // Process affiliate referral if code exists + if (affiliateCode && session.amount_total) { + try { + // Get frozen rates from checkout metadata for audit trail + // commissionRate = effective rate (originalRate - discountRate) + const frozenCommissionRate = session.metadata?.commissionRate + ? parseInt(session.metadata.commissionRate, 10) + : undefined; + const frozenDiscountRate = session.metadata?.discountRate + ? parseInt(session.metadata.discountRate, 10) + : undefined; + const frozenOriginalCommissionRate = session.metadata?.originalCommissionRate + ? parseInt(session.metadata.originalCommissionRate, 10) + : undefined; + + // Check if affiliate has Stripe Connect enabled (transfer_data was used) + const affiliate = await getAffiliateByCode(affiliateCode); + const isAutoTransfer = !!(affiliate?.stripeConnectAccountId && affiliate?.stripePayoutsEnabled); + + const referral = await processAffiliateReferralUseCase({ + affiliateCode, + purchaserId: parseInt(userId), + stripeSessionId: session.id, + amount: session.amount_total, + frozenCommissionRate, + frozenDiscountRate, + frozenOriginalCommissionRate, + isAutoTransfer, // If true, Stripe handles transfer via transfer_data + }); + + if (referral) { + logger.info("Successfully processed affiliate referral", { + fn: "stripe-webhook", + affiliateCode, + sessionId: session.id, + commission: referral.commission / 100, + isAutoTransfer, + }); + } else { + logger.warn("Affiliate referral not processed", { + fn: "stripe-webhook", + affiliateCode, + sessionId: session.id, + reason: "likely duplicate, self-referral, or invalid code", }); - console.log( - `Tracked purchase completion for analytics session ${analyticsSessionId}${gclid ? ` (Google Ads: ${gclid})` : ""}` - ); - } catch (error) { - console.error( - "Failed to track purchase completion:", - error - ); - // Don't fail the webhook for analytics errors } - } else { - console.warn( - `No analyticsSessionId found in Stripe metadata for user ${userId}. Purchase not tracked in analytics.` - ); + } catch (error) { + logger.error("Failed to process affiliate referral", { + fn: "stripe-webhook", + affiliateCode, + purchaserId: userId, + sessionId: session.id, + amount: session.amount_total, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + // Don't fail the webhook for affiliate errors - user upgrade should succeed } + } + } - // Process affiliate referral if code exists - if (affiliateCode && session.amount_total) { - try { - const referral = await processAffiliateReferralUseCase({ - affiliateCode, - purchaserId: parseInt(userId), - stripeSessionId: session.id, - amount: session.amount_total, + logger.info("Payment successful", { fn: "stripe-webhook", sessionId: session.id }); + break; + } + + // Handle Stripe Connect account updates + case "account.updated": { + const account = event.data.object; + logger.info("Stripe Connect account updated", { fn: "stripe-webhook", accountId: account.id }); + + try { + const result = await syncStripeAccountStatusUseCase(account.id); + if (result.success) { + logger.info("Successfully synced Stripe account", { + fn: "stripe-webhook", + accountId: account.id, + payoutsEnabled: account.payouts_enabled, + chargesEnabled: account.charges_enabled, + }); + } else { + logger.warn("Could not sync Stripe account", { + fn: "stripe-webhook", + accountId: account.id, + error: result.error, + }); + } + } catch (error) { + logger.error("Error syncing Stripe account", { + fn: "stripe-webhook", + accountId: account.id, + error: error instanceof Error ? error.message : String(error), + }); + // Don't fail webhook for sync errors + } + break; + } + + // Handle transfer creation (transfer initiated but funds not yet arrived) + case "transfer.created": { + const transfer = event.data.object; + logger.info("Stripe transfer initiated (pending)", { + fn: "stripe-webhook", + transferId: transfer.id, + amount: transfer.amount / 100, + destination: transfer.destination, + }); + + // Log transfer details for auditing + const metadata = transfer.metadata || {}; + if (metadata.affiliateId) { + logger.info("Transfer initiated for affiliate", { + fn: "stripe-webhook", + transferId: transfer.id, + affiliateId: metadata.affiliateId, + affiliateCode: metadata.affiliateCode, + }); + } + break; + } + + default: { + // Handle transfer.paid, transfer.failed and other events that may not be in the type definitions + // Cast to string to allow comparison with event types not in Stripe's type definitions + const eventType: string = event.type; + + // Handle transfer.paid - confirms funds have arrived at destination + if (eventType === "transfer.paid") { + const transfer = event.data.object as Stripe.Transfer; + + logger.info("Stripe transfer paid", { + fn: "stripe-webhook", + transferId: transfer.id, + amount: transfer.amount / 100, + destination: transfer.destination, + }); + + // Log confirmation for affiliate transfers + const metadata = transfer.metadata || {}; + if (metadata.affiliateId) { + logger.info("Transfer funds arrived for affiliate", { + fn: "stripe-webhook", + transferId: transfer.id, + affiliateId: metadata.affiliateId, + affiliateCode: metadata.affiliateCode, + }); + } + } + + // Handle transfer.failed + if (eventType === "transfer.failed") { + const transfer = event.data.object as Stripe.Transfer; + // Note: failure_message is available on Transfer objects in failed state + const transferWithFailure = transfer as Stripe.Transfer & { failure_message?: string }; + logger.error("Stripe transfer failed", { + fn: "stripe-webhook", + transferId: transfer.id, + amount: transfer.amount / 100, + destination: transfer.destination, + }); + + // Extract raw error message from transfer for logging + const rawErrorMessage = transferWithFailure.failure_message || "Transfer failed - unknown reason"; + // Convert to user-friendly message for storage and emails + const userFriendlyError = getUserFriendlyPayoutError(transferWithFailure.failure_message); + + // Log error details for admin notification (with raw error for debugging) + const metadata = transfer.metadata || {}; + if (metadata.affiliateId) { + logger.error("Failed transfer was for affiliate", { + fn: "stripe-webhook", + transferId: transfer.id, + affiliateId: metadata.affiliateId, + affiliateCode: metadata.affiliateCode, + rawError: rawErrorMessage, + note: "Manual intervention may be required", + }); + } + + // Try to get affiliate by destination (Stripe Connect account ID) and save the error + try { + const destination = transfer.destination; + if (typeof destination === "string") { + const affiliate = await getAffiliateByStripeAccountIdWithUserEmail(destination); + if (affiliate) { + // Save user-friendly error to affiliate record + await updateAffiliatePayoutError( + affiliate.id, + userFriendlyError, + new Date() + ); + logger.info("Saved payout error for affiliate", { + fn: "stripe-webhook", + affiliateId: affiliate.id, + userFriendlyError, + rawError: rawErrorMessage, }); - if (referral) { - console.log( - `Successfully processed affiliate referral for code ${affiliateCode}, session ${session.id}, commission: $${referral.commission / 100}` - ); - } else { - console.warn( - `Affiliate referral not processed for code ${affiliateCode}, session ${session.id} - likely duplicate, self-referral, or invalid code` - ); + // Send failure notification email with user-friendly message + if (affiliate.userEmail) { + const failureDate = new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + await sendAffiliatePayoutFailedEmail(affiliate.userEmail, { + affiliateName: affiliate.userName || "Affiliate Partner", + errorMessage: userFriendlyError, + failureDate, + }); + logger.info("Sent payout failure email", { + fn: "stripe-webhook", + affiliateId: affiliate.id, + }); } - } catch (error) { - console.error( - `Failed to process affiliate referral for code ${affiliateCode}, session ${session.id}:`, - { - error: - error instanceof Error - ? error.message - : String(error), - stack: error instanceof Error ? error.stack : undefined, - affiliateCode, - purchaserId: userId, - sessionId: session.id, - amount: session.amount_total, - } - ); - // Don't fail the webhook for affiliate errors - user upgrade should succeed } } + } catch (error) { + logger.error("Error saving payout error to affiliate", { + fn: "stripe-webhook", + error: error instanceof Error ? error.message : String(error), + }); } - console.log("Payment successful:", session.id); - break; + // Check if we have a payout record for this transfer + try { + const existingPayout = await getPayoutByStripeTransferId(transfer.id); + if (existingPayout) { + logger.error("Payout record exists for failed transfer", { + fn: "stripe-webhook", + payoutId: existingPayout.id, + transferId: transfer.id, + note: "Admin should review and potentially reverse", + }); + } + } catch (error) { + logger.error("Error checking payout record for failed transfer", { + fn: "stripe-webhook", + error: error instanceof Error ? error.message : String(error), + }); + } } + break; } + } - return new Response(JSON.stringify({ received: true }), { + return new Response(JSON.stringify({ received: true }), { + headers: { "Content-Type": "application/json" }, + }); + } catch (processingError) { + // Log the error but return 200 to acknowledge receipt + // Returning non-2xx would cause Stripe to retry, but since we've verified + // the signature, the issue is likely with our processing logic + logger.error("Webhook processing error", { + fn: "stripe-webhook", + eventType: event.type, + error: processingError instanceof Error ? processingError.message : String(processingError), + stack: processingError instanceof Error ? processingError.stack : undefined, + }); + return new Response( + JSON.stringify({ received: true, processingError: true }), + { + status: 200, headers: { "Content-Type": "application/json" }, - }); - } catch (err) { - console.error("Webhook Error:", { - error: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined, - errorType: err instanceof Error ? err.constructor.name : typeof err, - }); - return new Response( - JSON.stringify({ - error: "Webhook handler failed", - message: err instanceof Error ? err.message : String(err), - }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - } - ); - } + } + ); + } }, }, }, diff --git a/src/routes/purchase.tsx b/src/routes/purchase.tsx index 1f64b222..411cbc26 100644 --- a/src/routes/purchase.tsx +++ b/src/routes/purchase.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; +import Stripe from "stripe"; import { stripe } from "~/lib/stripe"; import { authenticatedMiddleware } from "~/lib/auth"; import { env } from "~/utils/env"; @@ -30,20 +31,71 @@ import { Link } from "@tanstack/react-router"; import { shouldShowEarlyAccessFn } from "~/fn/early-access"; import { useAnalytics } from "~/hooks/use-analytics"; import { trackPurchaseIntentFn } from "~/fn/analytics"; -import { PRICING_CONFIG } from "~/config"; +import { getPricingSettingsFn } from "~/fn/app-settings"; +import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; + +/** Conversion factor: multiply dollars by this to get cents */ +const CENTS_PER_DOLLAR = 100; +/** Divisor for converting percentage values (e.g., 30% -> 0.30) */ +const PERCENTAGE_DIVISOR = 100; + +/** Load Stripe once at module level for efficiency */ +const stripePromise = loadStripe(publicEnv.VITE_STRIPE_PUBLISHABLE_KEY); const searchSchema = z.object({ ref: z.string().optional(), checkout: z.boolean().optional(), }); +const getAffiliateInfoSchema = z.object({ + code: z.string(), +}); + +const getAffiliateInfoFn = createServerFn() + .inputValidator(getAffiliateInfoSchema) + .handler(async ({ data }) => { + const { getAffiliateByCode } = await import("~/data-access/affiliates"); + const { getProfile } = await import("~/data-access/profiles"); + const affiliate = await getAffiliateByCode(data.code); + + if (!affiliate || !affiliate.isActive) { + return null; + } + + // Get display name if enabled + let displayName = ""; + const profile = await getProfile(affiliate.userId); + if (profile?.useDisplayName && profile.displayName) { + displayName = profile.displayName; + } + + return { + discountRate: affiliate.discountRate, + commissionRate: affiliate.commissionRate - affiliate.discountRate, + displayName, + }; + }); + export const Route = createFileRoute("/purchase")({ - validateSearch: searchSchema, - loader: async () => { - const shouldShowEarlyAccess = await shouldShowEarlyAccessFn(); - return { shouldShowEarlyAccess }; + validateSearch: (search: Record) => searchSchema.parse(search), + loaderDeps: ({ search }) => ({ ref: search.ref, checkout: search.checkout }), + loader: async ({ deps: { ref } }) => { + // Load pricing and early access settings in parallel + const [shouldShowEarlyAccess, pricing] = await Promise.all([ + shouldShowEarlyAccessFn(), + getPricingSettingsFn(), + ]); + + // Get affiliate info if ref code is present + let affiliateInfo = null; + if (ref) { + affiliateInfo = await getAffiliateInfoFn({ data: { code: ref } }); + } + + return { shouldShowEarlyAccess, affiliateInfo, pricing }; }, component: RouteComponent, + errorComponent: DefaultCatchBoundary, }); const checkoutSchema = z.object({ @@ -64,8 +116,39 @@ const checkoutFn = createServerFn() userId: context.userId.toString(), }; + // Look up affiliate for discount and commission info + let affiliateDiscount = 0; + let affiliateCommission = 0; + let affiliateName = ""; + let affiliateStripeAccountId: string | null = null; + if (data.affiliateCode) { - metadata.affiliateCode = data.affiliateCode; + const { getAffiliateByCode } = await import("~/data-access/affiliates"); + const { getProfile } = await import("~/data-access/profiles"); + const affiliate = await getAffiliateByCode(data.affiliateCode); + + if (affiliate && affiliate.isActive) { + metadata.affiliateCode = data.affiliateCode; + metadata.affiliateId = affiliate.id.toString(); + // Store the rates at checkout time (frozen for this transaction) + metadata.discountRate = affiliate.discountRate.toString(); + metadata.commissionRate = (affiliate.commissionRate - affiliate.discountRate).toString(); + metadata.originalCommissionRate = affiliate.commissionRate.toString(); + + affiliateDiscount = affiliate.discountRate; + affiliateCommission = affiliate.commissionRate - affiliate.discountRate; + + // Store Stripe Connect account ID for automatic transfer + if (affiliate.stripeConnectAccountId && affiliate.stripePayoutsEnabled) { + affiliateStripeAccountId = affiliate.stripeConnectAccountId; + } + + // Get affiliate display name + const profile = await getProfile(affiliate.userId); + if (profile?.useDisplayName && profile.displayName) { + affiliateName = profile.displayName; + } + } } if (data.analyticsSessionId) { @@ -94,9 +177,34 @@ const checkoutFn = createServerFn() const successUrl = `${env.HOST_NAME}/success`; - const sessionConfig: any = { + // Get current price from database (with fallback to config) + const { getPricingCurrentPrice } = await import("~/data-access/app-settings"); + const currentPriceDollars = await getPricingCurrentPrice(); + + // Calculate the final price (in cents) + const basePriceInCents = currentPriceDollars * CENTS_PER_DOLLAR; + let finalPriceInCents = basePriceInCents; + + // Apply affiliate discount if set + if (affiliateDiscount > 0) { + finalPriceInCents = Math.round(basePriceInCents * (1 - affiliateDiscount / PERCENTAGE_DIVISOR)); + } + + const sessionConfig: Stripe.Checkout.SessionCreateParams = { payment_method_types: ["card"], - line_items: [{ price: env.STRIPE_PRICE_ID, quantity: 1 }], + line_items: [ + { + price_data: { + currency: "usd", + product_data: { + name: "Agentic Coding Mastery Course", + description: "Lifetime access to AI-first development training", + }, + unit_amount: finalPriceInCents, + }, + quantity: 1, + }, + ], mode: "payment", success_url: successUrl, customer_email: context.email, @@ -104,18 +212,26 @@ const checkoutFn = createServerFn() metadata, }; - // Apply discount if a valid discount code is provided - if (data.discountCode && env.STRIPE_DISCOUNT_COUPON_ID) { - sessionConfig.discounts = [ - { - coupon: env.STRIPE_DISCOUNT_COUPON_ID, + // Add automatic transfer to affiliate's Stripe Connect account + // Stripe will automatically transfer when funds are available (no manual retry needed) + if (affiliateStripeAccountId && affiliateCommission > 0) { + const commissionAmountCents = Math.floor((finalPriceInCents * affiliateCommission) / PERCENTAGE_DIVISOR); + sessionConfig.payment_intent_data = { + transfer_data: { + destination: affiliateStripeAccountId, + amount: commissionAmountCents, }, - ]; + }; } const session = await stripe.checkout.sessions.create(sessionConfig); - return { sessionId: session.id }; + return { + sessionId: session.id, + affiliateDiscount, + affiliateCommission, + affiliateName, + }; }); const features = [ @@ -162,6 +278,40 @@ function RouteComponent() { const { sessionId } = useAnalytics(); const navigate = Route.useNavigate(); const hasTriggeredCheckout = React.useRef(false); + const { affiliateInfo, pricing } = Route.useLoaderData(); + + // Calculate discounted price if affiliate has discount + const hasAffiliateDiscount = affiliateInfo && affiliateInfo.discountRate > 0; + const discountedPrice = hasAffiliateDiscount + ? Math.round(pricing.currentPrice * (1 - affiliateInfo.discountRate / PERCENTAGE_DIVISOR)) + : pricing.currentPrice; + + + const proceedToCheckout = React.useCallback(async (affiliateCode: string) => { + const stripeResolved = await stripePromise; + if (!stripeResolved) throw new Error("Stripe failed to initialize"); + + try { + const { sessionId: stripeSessionId } = await checkoutFn({ + data: { + affiliateCode: affiliateCode || undefined, + // discountCode: affiliateCode || undefined, // Discount codes not implemented yet + analyticsSessionId: sessionId || undefined, // Pass analytics session ID (convert null to undefined) + }, + }); + const { error } = await stripeResolved.redirectToCheckout({ + sessionId: stripeSessionId, + }); + if (error) throw error; + } catch (error) { + console.error("Payment error:", error); + } + }, [sessionId]); + + // Calculate discount percentage from original to current price + const discountPercentage = pricing.originalPrice > pricing.currentPrice + ? Math.round(((pricing.originalPrice - pricing.currentPrice) / pricing.originalPrice) * 100) + : 0; // Auto-trigger checkout if user just logged in with checkout intent // Wait for sessionId to be available (from sessionStorage after OAuth redirect) @@ -182,7 +332,7 @@ function RouteComponent() { // Proceed to checkout proceedToCheckout(ref || ""); } - }, [user, checkout, ref, navigate, sessionId]); + }, [user, checkout, ref, navigate, sessionId, proceedToCheckout]); const handlePurchaseClick = async () => { // Track purchase intent @@ -203,27 +353,7 @@ function RouteComponent() { await proceedToCheckout(ref || ""); }; - const proceedToCheckout = async (affiliateCode: string) => { - const stripePromise = loadStripe(publicEnv.VITE_STRIPE_PUBLISHABLE_KEY); - const stripeResolved = await stripePromise; - if (!stripeResolved) throw new Error("Stripe failed to initialize"); - try { - const { sessionId: stripeSessionId } = await checkoutFn({ - data: { - affiliateCode: affiliateCode || undefined, - // discountCode: affiliateCode || undefined, // Discount codes not implemented yet - analyticsSessionId: sessionId || undefined, // Pass analytics session ID (convert null to undefined) - }, - }); - const { error } = await stripeResolved.redirectToCheckout({ - sessionId: stripeSessionId, - }); - if (error) throw error; - } catch (error) { - console.error("Payment error:", error); - } - }; return (
@@ -242,7 +372,12 @@ function RouteComponent() { >
- Limited Time Offer - {PRICING_CONFIG.DISCOUNT_PERCENTAGE}% OFF + {pricing.promoLabel}{discountPercentage > 0 ? ` - ${discountPercentage}% OFF` : ""} + {hasAffiliateDiscount && ( + + + {affiliateInfo.discountRate}% extra + + )}
@@ -304,21 +439,68 @@ function RouteComponent() { {/* Pricing */}
-
- Regular price{" "} - - {PRICING_CONFIG.FORMATTED_ORIGINAL_PRICE} - -
-
- {PRICING_CONFIG.FORMATTED_CURRENT_PRICE} -
-
- Limited Time Offer -
-
- One-time payment, lifetime access -
+ {hasAffiliateDiscount ? ( + <> + {/* Always show original price as crossed out */} +
+ Regular price{" "} + + ${pricing.originalPrice} + +
+
+ ${discountedPrice} +
+ {/* Promo as main discount */} + {pricing.promoLabel && ( +
+ {pricing.promoLabel} + {discountPercentage > 0 && ` - ${discountPercentage}% OFF`} +
+ )} + {/* Affiliate discount as extra */} +
+ + {affiliateInfo.discountRate}% extra + {affiliateInfo.displayName && ( + + {" "}via {affiliateInfo.displayName} + + )} +
+
+ One-time payment, lifetime access +
+ {/* Show total savings from original */} + {pricing.originalPrice > discountedPrice && ( +
+ Total savings: {Math.round(((pricing.originalPrice - discountedPrice) / pricing.originalPrice) * 100)}% off original +
+ )} + + ) : ( + <> + {pricing.originalPrice > pricing.currentPrice && ( +
+ Regular price{" "} + + ${pricing.originalPrice} + +
+ )} +
+ ${pricing.currentPrice} +
+ {pricing.promoLabel && ( +
+ {pricing.promoLabel} + {discountPercentage > 0 && ` - ${discountPercentage}% OFF`} +
+ )} +
+ One-time payment, lifetime access +
+ + )}
diff --git a/src/types/data-table.ts b/src/types/data-table.ts new file mode 100644 index 00000000..d785f9e0 --- /dev/null +++ b/src/types/data-table.ts @@ -0,0 +1,53 @@ +import type { ColumnSort, Row, RowData } from "@tanstack/react-table"; +import type { DataTableConfig } from "~/config/data-table"; +import type { FilterItemSchema } from "~/lib/parsers"; + +declare module "@tanstack/react-table" { + // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface + interface TableMeta { + queryKeys?: QueryKeys; + } + + // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface + interface ColumnMeta { + label?: string; + placeholder?: string; + variant?: FilterVariant; + options?: Option[]; + range?: [number, number]; + unit?: string; + icon?: React.FC>; + } +} + +export interface QueryKeys { + page: string; + perPage: string; + sort: string; + filters: string; + joinOperator: string; +} + +export interface Option { + label: string; + value: string; + count?: number; + icon?: React.FC>; +} + +export type FilterOperator = DataTableConfig["operators"][number]; +export type FilterVariant = DataTableConfig["filterVariants"][number]; +export type JoinOperator = DataTableConfig["joinOperators"][number]; + +export interface ExtendedColumnSort extends Omit { + id: Extract; +} + +export interface ExtendedColumnFilter extends FilterItemSchema { + id: Extract; +} + +export interface DataTableRowAction { + row: Row; + variant: "update" | "delete"; +} diff --git a/src/use-cases/affiliates.ts b/src/use-cases/affiliates.ts index bbc8ac69..35cd84ba 100644 --- a/src/use-cases/affiliates.ts +++ b/src/use-cases/affiliates.ts @@ -3,26 +3,85 @@ import { createAffiliate, getAffiliateByUserId, getAffiliateByCode, - createAffiliateReferral, - updateAffiliateBalances, createAffiliatePayout, - getAffiliateByStripeSession, updateAffiliateProfile, getAffiliateStats, getAffiliateReferrals, getAffiliatePayouts, getMonthlyAffiliateEarnings, getAllAffiliatesWithStats, + getAffiliateById, + getEligibleAffiliatesForAutoPayout, + createAffiliatePayoutWithStripeTransfer, + getPayoutByStripeTransferId, + updateAffiliateStripeAccount, + getAffiliateByStripeAccountId, + clearAffiliatePayoutError, + disconnectAffiliateStripeAccount, + getAffiliateWithUserEmail, + incrementPayoutRetryCount, + resetPayoutRetryCount, + createPendingPayout, + completePendingPayout, + failPendingPayout, + // Transaction-aware functions for processAffiliateReferralUseCase + getAffiliateByCodeTx, + getAffiliateByStripeSessionTx, + createAffiliateReferralTx, + updateAffiliateBalancesTx, + incrementAffiliatePaidAmountTx, + updateAffiliateCommissionRate, } from "~/data-access/affiliates"; +import { getAffiliateCommissionRate, getAffiliateMinimumPayout } from "~/data-access/app-settings"; import { ApplicationError } from "./errors"; import { AFFILIATE_CONFIG } from "~/config"; +import { stripe } from "~/lib/stripe"; +import { determineStripeAccountStatus } from "~/utils/stripe-status"; +import { sendAffiliatePayoutSuccessEmail } from "~/utils/email"; +import { logger } from "~/utils/logger"; + +/** + * Validates a payment link URL. + * Ensures the URL is valid and uses https:// scheme for security. + * @param paymentLink - The payment link to validate + * @throws ApplicationError if the payment link is invalid + */ +function validatePaymentLink(paymentLink: string | undefined): void { + if (!paymentLink || paymentLink.length < 10) { + throw new ApplicationError( + "Please provide a valid payment link", + "INVALID_PAYMENT_LINK" + ); + } + + // Validate it's a URL with https:// scheme + let parsedUrl: URL; + try { + parsedUrl = new URL(paymentLink); + } catch { + throw new ApplicationError( + "Payment link must be a valid URL", + "INVALID_PAYMENT_LINK" + ); + } + + // Require https:// for security + if (parsedUrl.protocol !== "https:") { + throw new ApplicationError( + "Payment link must use https:// for security", + "INVALID_PAYMENT_LINK" + ); + } +} export async function registerAffiliateUseCase({ userId, + paymentMethod, paymentLink, }: { userId: number; - paymentLink: string; + paymentMethod: "link" | "stripe"; + paymentLink?: string; }) { // Check if user already is an affiliate const existingAffiliate = await getAffiliateByUserId(userId); @@ -33,33 +92,24 @@ export async function registerAffiliateUseCase({ ); } - // Validate payment link (basic validation) - if (!paymentLink || paymentLink.length < 10) { - throw new ApplicationError( - "Please provide a valid payment link", - "INVALID_PAYMENT_LINK" - ); - } - - // Validate it's a URL - try { - new URL(paymentLink); - } catch { - throw new ApplicationError( - "Payment link must be a valid URL", - "INVALID_PAYMENT_LINK" - ); + // Validate payment link if using link method + if (paymentMethod === "link") { + validatePaymentLink(paymentLink); } // Generate unique affiliate code const affiliateCode = await generateUniqueAffiliateCode(); + // Get commission rate from DB settings (falls back to AFFILIATE_CONFIG.COMMISSION_RATE) + const commissionRate = await getAffiliateCommissionRate(); + // Create affiliate const affiliate = await createAffiliate({ userId, affiliateCode, - paymentLink, - commissionRate: AFFILIATE_CONFIG.COMMISSION_RATE, + paymentMethod, + paymentLink: paymentLink && paymentLink.length > 0 ? paymentLink : null, + commissionRate, totalEarnings: 0, paidAmount: 0, unpaidBalance: 0, @@ -69,56 +119,232 @@ export async function registerAffiliateUseCase({ return affiliate; } +export async function validateAffiliateCodeUseCase(code: string) { + if (!code) return null; + + const affiliate = await getAffiliateByCode(code); + if (!affiliate || !affiliate.isActive) { + return null; + } + + return affiliate; +} + +export async function updateAffiliatePaymentLinkUseCase({ + userId, + paymentMethod, + paymentLink, +}: { + userId: number; + paymentMethod: "link" | "stripe"; + paymentLink?: string; +}) { + const affiliate = await getAffiliateByUserId(userId); + if (!affiliate) { + throw new ApplicationError( + "You are not registered as an affiliate", + "NOT_AFFILIATE" + ); + } + + // Validate payment link if using link method + if (paymentMethod === "link") { + validatePaymentLink(paymentLink); + } + + // Update payment method and link in database + return updateAffiliateProfile(affiliate.id, { + paymentMethod, + paymentLink: paymentLink && paymentLink.length > 0 ? paymentLink : null, + }); +} + +export async function adminToggleAffiliateStatusUseCase({ + affiliateId, + isActive, +}: { + affiliateId: number; + isActive: boolean; +}) { + // Verify affiliate exists before updating + const affiliate = await getAffiliateById(affiliateId); + if (!affiliate) { + throw new ApplicationError( + "Affiliate not found", + "AFFILIATE_NOT_FOUND" + ); + } + + return updateAffiliateProfile(affiliateId, { isActive }); +} + +export async function adminUpdateAffiliateCommissionRateUseCase({ + affiliateId, + commissionRate, +}: { + affiliateId: number; + commissionRate: number; +}) { + // Verify affiliate exists before updating + const affiliate = await getAffiliateById(affiliateId); + if (!affiliate) { + throw new ApplicationError( + "Affiliate not found", + "AFFILIATE_NOT_FOUND" + ); + } + + if (commissionRate < 0 || commissionRate > 100) { + throw new ApplicationError( + "Commission rate must be between 0 and 100", + "INVALID_COMMISSION_RATE" + ); + } + + return updateAffiliateCommissionRate(affiliateId, commissionRate); +} + +async function generateUniqueAffiliateCode(): Promise { + let attempts = 0; + + while (attempts < AFFILIATE_CONFIG.AFFILIATE_CODE_RETRY_ATTEMPTS) { + // Generate a random affiliate code + const bytes = randomBytes(6); + const code = bytes + .toString("base64") + .replace(/[^a-zA-Z0-9]/g, "") + .substring(0, AFFILIATE_CONFIG.AFFILIATE_CODE_LENGTH) + .toUpperCase(); + + // Ensure it's exactly the required length (pad if needed) + const paddedCode = code.padEnd(AFFILIATE_CONFIG.AFFILIATE_CODE_LENGTH, "0"); + + // Check if this code is already in use + const existingAffiliate = await getAffiliateByCode(paddedCode); + if (!existingAffiliate) { + return paddedCode; + } + + attempts++; + } + + throw new ApplicationError( + "Unable to generate unique affiliate code after multiple attempts", + "CODE_GENERATION_FAILED" + ); +} + export async function processAffiliateReferralUseCase({ affiliateCode, purchaserId, stripeSessionId, amount, + frozenCommissionRate, + frozenDiscountRate, + frozenOriginalCommissionRate, + isAutoTransfer = false, }: { affiliateCode: string; purchaserId: number; stripeSessionId: string; amount: number; + /** Commission rate frozen at checkout time (effective rate = originalRate - discountRate). If not provided, uses affiliate's current rate. */ + frozenCommissionRate?: number; + /** Discount rate frozen at checkout time. */ + frozenDiscountRate?: number; + /** Original commission rate frozen at checkout time. */ + frozenOriginalCommissionRate?: number; + /** If true, transfer_data was used in checkout - referral is marked as paid immediately (Stripe handles transfer) */ + isAutoTransfer?: boolean; }) { + // Validate amount is within safe range to prevent integer overflow in commission calculation + if (amount < 0) { + logger.warn("Invalid negative amount for affiliate referral", { + fn: "processAffiliateReferralUseCase", + amount, + stripeSessionId, + }); + return null; + } + + if (amount > AFFILIATE_CONFIG.MAX_PURCHASE_AMOUNT) { + logger.warn("Amount exceeds maximum allowed for affiliate referral", { + fn: "processAffiliateReferralUseCase", + amount, + maxAllowed: AFFILIATE_CONFIG.MAX_PURCHASE_AMOUNT, + stripeSessionId, + }); + return null; + } + // Import database for transaction support const { database } = await import("~/db"); - + return await database.transaction(async (tx) => { - // Get affiliate by code (using the imported function which uses the main database) - const affiliate = await getAffiliateByCode(affiliateCode); + // Get affiliate by code using transaction-aware function + const affiliate = await getAffiliateByCodeTx(tx, affiliateCode); if (!affiliate) { - console.warn(`Invalid affiliate code: ${affiliateCode} for purchase ${stripeSessionId}`); + logger.warn("Invalid affiliate code for purchase", { + fn: "processAffiliateReferralUseCase", + affiliateCode, + stripeSessionId, + }); return null; } // Check for self-referral if (affiliate.userId === purchaserId) { - console.warn(`Self-referral attempted by user ${purchaserId} for session ${stripeSessionId}`); + logger.warn("Self-referral attempted", { + fn: "processAffiliateReferralUseCase", + purchaserId, + stripeSessionId, + }); return null; } - // Check if this session was already processed (database unique constraint also helps with race conditions) - const existingReferral = await getAffiliateByStripeSession(stripeSessionId); + // Check if this session was already processed using transaction-aware function + // (database unique constraint also helps with race conditions) + const existingReferral = await getAffiliateByStripeSessionTx(tx, stripeSessionId); if (existingReferral) { - console.warn(`Duplicate Stripe session: ${stripeSessionId} already processed`); + logger.warn("Duplicate Stripe session already processed", { + fn: "processAffiliateReferralUseCase", + stripeSessionId, + }); return null; } - // Calculate commission - const commission = Math.floor((amount * affiliate.commissionRate) / 100); + // Calculate commission using frozen rate (from checkout) or affiliate's current rate + // frozenCommissionRate is the commission rate stored at checkout time (after discount split) + const effectiveCommissionRate = frozenCommissionRate ?? affiliate.commissionRate; + const commission = Math.floor((amount * effectiveCommissionRate) / 100); - // Create referral record - const referral = await createAffiliateReferral({ + // Create referral record with frozen rates for audit trail using transaction + // If isAutoTransfer (transfer_data used), mark as paid immediately - Stripe handles the transfer + const referral = await createAffiliateReferralTx(tx, { affiliateId: affiliate.id, purchaserId, stripeSessionId, amount, commission, - isPaid: false, + // Store frozen rates at checkout time for audit trail + commissionRate: effectiveCommissionRate, + discountRate: frozenDiscountRate ?? affiliate.discountRate, + originalCommissionRate: frozenOriginalCommissionRate ?? affiliate.commissionRate, + isPaid: isAutoTransfer, // Paid immediately if Stripe handles transfer }); - // Update affiliate balances - await updateAffiliateBalances(affiliate.id, commission, commission); + // Update affiliate balances using transaction-aware functions + // If auto-transfer: add to totalEarnings and paidAmount (not unpaidBalance) + // If manual: add to totalEarnings and unpaidBalance + if (isAutoTransfer) { + // For auto-transfer, we add to totalEarnings (unpaid stays same) + await updateAffiliateBalancesTx(tx, affiliate.id, commission, 0); + // Also increment paidAmount since Stripe will transfer it (using data-access layer) + await incrementAffiliatePaidAmountTx(tx, affiliate.id, commission); + } else { + // For manual payout, add to both totalEarnings and unpaidBalance + await updateAffiliateBalancesTx(tx, affiliate.id, commission, commission); + } return referral; }); @@ -139,10 +365,23 @@ export async function recordAffiliatePayoutUseCase({ notes?: string; paidBy: number; }) { - // Validate minimum payout - if (amount < AFFILIATE_CONFIG.MINIMUM_PAYOUT) { + // Validate affiliate exists and is active + const affiliate = await getAffiliateById(affiliateId); + if (!affiliate) { + throw new ApplicationError("Affiliate not found", "AFFILIATE_NOT_FOUND"); + } + if (!affiliate.isActive) { throw new ApplicationError( - `Minimum payout amount is $${AFFILIATE_CONFIG.MINIMUM_PAYOUT / 100}`, + "Affiliate account is not active", + "AFFILIATE_INACTIVE" + ); + } + + // Validate minimum payout (configurable via admin settings) + const minimumPayout = await getAffiliateMinimumPayout(); + if (amount < minimumPayout) { + throw new ApplicationError( + `Minimum payout amount is $${minimumPayout / 100}`, "MINIMUM_PAYOUT_NOT_MET" ); } @@ -160,51 +399,348 @@ export async function recordAffiliatePayoutUseCase({ return payout; } -export async function validateAffiliateCodeUseCase(code: string) { - if (!code) return null; +/** + * Process automatic payout for a single affiliate via Stripe Connect. + * Uses a two-phase approach to prevent the critical scenario where Stripe transfer + * succeeds but database recording fails (money transferred but not recorded). + * + * Phase 1: Create a "pending" payout record BEFORE initiating the transfer + * Phase 2: Update the record to "completed" after successful transfer + * Phase 3: If transfer fails, update record to "failed" + */ +export async function processAutomaticPayoutsUseCase({ + affiliateId, + systemUserId, +}: { + affiliateId: number; + systemUserId: number; // Admin/system user ID to record as paidBy +}): Promise<{ + success: boolean; + transferId?: string; + amount?: number; + error?: string; +}> { + // Get affiliate details first (before creating pending payout) + const affiliate = await getAffiliateById(affiliateId); + if (!affiliate) { + return { success: false, error: "Affiliate not found" }; + } - const affiliate = await getAffiliateByCode(code); - if (!affiliate || !affiliate.isActive) { - return null; + // Validate Stripe Connect is enabled + if (!affiliate.stripeConnectAccountId) { + return { success: false, error: "Affiliate has no Stripe Connect account" }; } - return affiliate; -} + if (!affiliate.stripePayoutsEnabled) { + return { success: false, error: "Stripe payouts not enabled for this affiliate" }; + } -export async function updateAffiliatePaymentLinkUseCase({ - userId, - paymentLink, -}: { - userId: number; - paymentLink: string; -}) { - const affiliate = await getAffiliateByUserId(userId); - if (!affiliate) { - throw new ApplicationError( - "You are not registered as an affiliate", - "NOT_AFFILIATE" + // Validate there's a balance to pay + // Stripe Connect affiliates: No minimum threshold - pay any positive balance + if (affiliate.unpaidBalance <= 0) { + return { + success: false, + error: "No unpaid balance to process", + }; + } + + if (!affiliate.isActive) { + return { success: false, error: "Affiliate account is not active" }; + } + + const payoutAmount = affiliate.unpaidBalance; + + // Phase 1: Create pending payout record BEFORE initiating Stripe transfer + // This ensures we have a database record even if the transfer succeeds but + // subsequent database operations fail + let pendingPayout: { id: number }; + try { + pendingPayout = await createPendingPayout({ + affiliateId: affiliate.id, + amount: payoutAmount, + paidBy: systemUserId, + }); + logger.info("Created pending payout record", { + fn: "processAutomaticPayoutsUseCase", + payoutId: pendingPayout.id, + affiliateId: affiliate.id, + amount: payoutAmount, + }); + } catch (dbError) { + const errorMessage = dbError instanceof Error ? dbError.message : String(dbError); + logger.error("Failed to create pending payout record", { + fn: "processAutomaticPayoutsUseCase", + affiliateId, + error: errorMessage, + }); + return { success: false, error: `Database error: ${errorMessage}` }; + } + + // Phase 2: Create Stripe Transfer to connected account + try { + // Use pending payout ID as idempotency key - guarantees uniqueness and prevents duplicates + // The pending payout ID is auto-incremented in the database, so it's always unique + const idempotencyKey = `payout-${pendingPayout.id}`; + + const transfer = await stripe.transfers.create( + { + amount: payoutAmount, + currency: "usd", + destination: affiliate.stripeConnectAccountId, + metadata: { + affiliateId: affiliate.id.toString(), + affiliateCode: affiliate.affiliateCode, + payoutType: "automatic", + pendingPayoutId: pendingPayout.id.toString(), + }, + }, + { + idempotencyKey, + } ); + + // Phase 3: Complete the pending payout record with transfer ID + // This updates the record to "completed" status and marks referrals as paid + try { + await completePendingPayout(pendingPayout.id, transfer.id); + } catch (completeError) { + // Critical: Transfer succeeded but completing the payout record failed + // The pending payout record still exists, so we have a record of the transfer + // An admin will need to manually reconcile this + const errorMessage = completeError instanceof Error ? completeError.message : String(completeError); + logger.error("CRITICAL: Transfer succeeded but failed to complete payout record", { + fn: "processAutomaticPayoutsUseCase", + affiliateId: affiliate.id, + payoutId: pendingPayout.id, + transferId: transfer.id, + error: errorMessage, + }); + // Still return success since the transfer went through + // The pending record exists for manual reconciliation + return { + success: true, + transferId: transfer.id, + amount: payoutAmount, + }; + } + + // Clear any previous payout error and reset retry count on successful transfer + await clearAffiliatePayoutError(affiliate.id); + await resetPayoutRetryCount(affiliate.id); + + logger.info("Automatic payout processed", { + fn: "processAutomaticPayoutsUseCase", + affiliateId: affiliate.id, + payoutId: pendingPayout.id, + amount: payoutAmount / 100, + transferId: transfer.id, + }); + + // Send success notification email to affiliate + try { + const affiliateWithEmail = await getAffiliateWithUserEmail(affiliate.id); + if (affiliateWithEmail?.userEmail) { + const formattedAmount = `$${(payoutAmount / 100).toFixed(2)}`; + const payoutDate = new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + await sendAffiliatePayoutSuccessEmail(affiliateWithEmail.userEmail, { + affiliateName: affiliateWithEmail.userName || "Affiliate Partner", + payoutAmount: formattedAmount, + payoutDate, + stripeTransferId: transfer.id, + }); + } + } catch (emailError) { + // Log but don't fail the payout for email errors + logger.error("Failed to send payout success email", { + fn: "processAutomaticPayoutsUseCase", + affiliateId: affiliate.id, + error: emailError instanceof Error ? emailError.message : String(emailError), + }); + } + + return { + success: true, + transferId: transfer.id, + amount: payoutAmount, + }; + } catch (transferError) { + // Transfer failed - mark the pending payout as failed + const errorMessage = transferError instanceof Error ? transferError.message : String(transferError); + + try { + await failPendingPayout(pendingPayout.id, errorMessage); + logger.info("Marked pending payout as failed", { + fn: "processAutomaticPayoutsUseCase", + payoutId: pendingPayout.id, + affiliateId, + error: errorMessage, + }); + } catch (failError) { + // Failed to mark the payout as failed - log but continue + logger.error("Failed to mark pending payout as failed", { + fn: "processAutomaticPayoutsUseCase", + payoutId: pendingPayout.id, + affiliateId, + originalError: errorMessage, + failError: failError instanceof Error ? failError.message : String(failError), + }); + } + + logger.error("Failed to process automatic payout", { + fn: "processAutomaticPayoutsUseCase", + affiliateId, + payoutId: pendingPayout.id, + error: errorMessage, + }); + + // Increment retry count on failure (uses exponential backoff: 1h, 4h, 24h, then stops) + await incrementPayoutRetryCount(affiliateId); + + return { success: false, error: errorMessage }; } +} - // Validate payment link - if (!paymentLink || paymentLink.length < 10) { - throw new ApplicationError( - "Please provide a valid payment link", - "INVALID_PAYMENT_LINK" +/** + * Process automatic payouts for all eligible affiliates. + * Used by admin to trigger batch processing. + * + * Implements controlled concurrency (3 at a time) with rate limiting + * to respect Stripe API limits and prevent overwhelming the system. + */ +export async function processAllAutomaticPayoutsUseCase({ + systemUserId, +}: { + systemUserId: number; +}): Promise<{ + processed: number; + successful: number; + failed: number; + results: Array<{ + affiliateId: number; + success: boolean; + transferId?: string; + amount?: number; + error?: string; + }>; +}> { + // Stripe Connect affiliates have no minimum threshold - pay any positive balance + const eligibleAffiliates = await getEligibleAffiliatesForAutoPayout(1); + + const results: Array<{ + affiliateId: number; + success: boolean; + transferId?: string; + amount?: number; + error?: string; + }> = []; + + // Process in batches to avoid overwhelming Stripe API + for (let i = 0; i < eligibleAffiliates.length; i += AFFILIATE_CONFIG.CONCURRENT_PAYOUTS) { + const batch = eligibleAffiliates.slice(i, i + AFFILIATE_CONFIG.CONCURRENT_PAYOUTS); + + // Process this batch concurrently + const batchResults = await Promise.all( + batch.map(async (affiliate) => { + const result = await processAutomaticPayoutsUseCase({ + affiliateId: affiliate.id, + systemUserId, + }); + return { affiliateId: affiliate.id, ...result }; + }) ); + + results.push(...batchResults); + + // Add delay between batches to respect rate limits (skip after last batch) + if (i + AFFILIATE_CONFIG.CONCURRENT_PAYOUTS < eligibleAffiliates.length) { + await new Promise((resolve) => setTimeout(resolve, AFFILIATE_CONFIG.BATCH_DELAY_MS)); + } } + return { + processed: results.length, + successful: results.filter((r) => r.success).length, + failed: results.filter((r) => !r.success).length, + results, + }; +} + +/** + * Sync Stripe Connect account status from Stripe API. + * Called when account.updated webhook is received or manually by user. + */ +export async function syncStripeAccountStatusUseCase( + stripeAccountId: string +): Promise<{ + success: boolean; + affiliate?: Awaited>; + error?: string; +}> { try { - new URL(paymentLink); - } catch { - throw new ApplicationError( - "Payment link must be a valid URL", - "INVALID_PAYMENT_LINK" - ); + // Get affiliate by Stripe account ID + const affiliate = await getAffiliateByStripeAccountId(stripeAccountId); + if (!affiliate) { + return { success: false, error: "No affiliate found with this Stripe account ID" }; + } + + // Fetch account details from Stripe + const account = await stripe.accounts.retrieve(stripeAccountId); + + // Determine account status + const status = determineStripeAccountStatus(account); + + // Update affiliate record + const updated = await updateAffiliateStripeAccount(affiliate.id, { + stripeAccountStatus: status, + stripeChargesEnabled: account.charges_enabled ?? false, + stripePayoutsEnabled: account.payouts_enabled ?? false, + stripeDetailsSubmitted: account.details_submitted ?? false, + lastStripeSync: new Date(), + }); + + logger.info("Synced Stripe account status", { + fn: "syncStripeAccountStatusUseCase", + affiliateId: affiliate.id, + status, + payoutsEnabled: account.payouts_enabled, + }); + + return { success: true, affiliate: updated }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("Failed to sync Stripe account", { + fn: "syncStripeAccountStatusUseCase", + stripeAccountId, + error: errorMessage, + }); + return { success: false, error: errorMessage }; + } +} + +/** + * Refresh Stripe account status for a user's affiliate account. + * Called manually by the user from the dashboard. + */ +export async function refreshStripeAccountStatusForUserUseCase( + userId: number +): Promise<{ + success: boolean; + error?: string; +}> { + const affiliate = await getAffiliateByUserId(userId); + if (!affiliate) { + return { success: false, error: "User is not an affiliate" }; } - // Update payment link in database - return updateAffiliateProfile(affiliate.id, { paymentLink }); + if (!affiliate.stripeConnectAccountId) { + return { success: false, error: "No Stripe Connect account linked" }; + } + + return syncStripeAccountStatusUseCase(affiliate.stripeConnectAccountId); } export async function getAffiliateAnalyticsUseCase(userId: number) { @@ -216,7 +752,18 @@ export async function getAffiliateAnalyticsUseCase(userId: number) { ); } - const [stats, referrals, payouts, monthlyEarnings] = await Promise.all([ + // Fetch Stripe account details if connected + let stripeAccountName: string | null = null; + if (affiliate.stripeConnectAccountId && affiliate.stripeAccountStatus === "active") { + try { + const stripeAccount = await stripe.accounts.retrieve(affiliate.stripeConnectAccountId); + stripeAccountName = stripeAccount.business_profile?.name ?? null; + } catch { + // Ignore errors - name is optional + } + } + + const [stats, referralsResult, payoutsResult, monthlyEarnings] = await Promise.all([ getAffiliateStats(affiliate.id), getAffiliateReferrals(affiliate.id), getAffiliatePayouts(affiliate.id), @@ -224,10 +771,13 @@ export async function getAffiliateAnalyticsUseCase(userId: number) { ]); return { - affiliate, + affiliate: { + ...affiliate, + stripeAccountName, // Add fetched name from Stripe API + }, stats, - referrals, - payouts, + referrals: referralsResult.items, + payouts: payoutsResult.items, monthlyEarnings, }; } @@ -236,42 +786,41 @@ export async function adminGetAllAffiliatesUseCase() { return getAllAffiliatesWithStats(); } -export async function adminToggleAffiliateStatusUseCase({ - affiliateId, - isActive, -}: { - affiliateId: number; - isActive: boolean; -}) { - return updateAffiliateProfile(affiliateId, { isActive }); -} +/** + * Disconnect Stripe Connect account for an affiliate. + * Resets all Stripe-related fields and switches payment method to link. + */ +export async function disconnectStripeAccountUseCase(userId: number) { + const affiliate = await getAffiliateByUserId(userId); + if (!affiliate) { + throw new ApplicationError( + "You are not registered as an affiliate", + "NOT_AFFILIATE" + ); + } -async function generateUniqueAffiliateCode(): Promise { - let attempts = 0; - - while (attempts < AFFILIATE_CONFIG.AFFILIATE_CODE_RETRY_ATTEMPTS) { - // Generate a random affiliate code - const bytes = randomBytes(6); - const code = bytes - .toString("base64") - .replace(/[^a-zA-Z0-9]/g, "") - .substring(0, AFFILIATE_CONFIG.AFFILIATE_CODE_LENGTH) - .toUpperCase(); + // Validate that they have a Stripe account connected + if ( + !affiliate.stripeConnectAccountId || + affiliate.stripeAccountStatus === "not_started" + ) { + throw new ApplicationError( + "No Stripe Connect account is connected", + "NO_STRIPE_ACCOUNT" + ); + } - // Ensure it's exactly the required length (pad if needed) - const paddedCode = code.padEnd(AFFILIATE_CONFIG.AFFILIATE_CODE_LENGTH, "0"); - - // Check if this code is already in use - const existingAffiliate = await getAffiliateByCode(paddedCode); - if (!existingAffiliate) { - return paddedCode; - } - - attempts++; + // Try to revoke on Stripe's side (best effort) + try { + await stripe.accounts.del(affiliate.stripeConnectAccountId); + } catch (error) { + logger.warn("Failed to delete Stripe account", { + fn: "disconnectStripeAccountUseCase", + stripeConnectAccountId: affiliate.stripeConnectAccountId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue with local cleanup } - - throw new ApplicationError( - "Unable to generate unique affiliate code after multiple attempts", - "CODE_GENERATION_FAILED" - ); + + return disconnectAffiliateStripeAccount(affiliate.id); } diff --git a/src/use-cases/app-settings.ts b/src/use-cases/app-settings.ts index 641a3a4a..3071da85 100644 --- a/src/use-cases/app-settings.ts +++ b/src/use-cases/app-settings.ts @@ -9,6 +9,14 @@ import { isNewsFeatureEnabled as checkNewsFeatureEnabled, isVideoSegmentContentTabsEnabled as checkVideoSegmentContentTabsEnabled, isFeatureFlagEnabled, + getAffiliateCommissionRate as getCommissionRateFromDb, + setAffiliateCommissionRate as setCommissionRateInDb, + getAffiliateMinimumPayout as getMinimumPayoutFromDb, + setAffiliateMinimumPayout as setMinimumPayoutInDb, + getPricingSettings as getPricingSettingsFromDb, + setPricingCurrentPrice as setCurrentPriceInDb, + setPricingOriginalPrice as setOriginalPriceInDb, + setPricingPromoLabel as setPromoLabelInDb, } from "~/data-access/app-settings"; import { type FlagKey } from "~/config"; @@ -96,3 +104,73 @@ export async function getFeatureFlagEnabledUseCase(flagKey: FlagKey) { export async function toggleFeatureFlagUseCase(flagKey: FlagKey, enabled: boolean) { await setAppSetting(flagKey, enabled.toString()); } + +/** + * Get the affiliate commission rate from app settings. + * Returns the rate as an integer percentage (e.g., 30 for 30%). + */ +export async function getAffiliateCommissionRateUseCase(): Promise { + return getCommissionRateFromDb(); +} + +/** + * Set the affiliate commission rate in app settings. + * @param rate - Integer percentage (0-100) + */ +export async function setAffiliateCommissionRateUseCase(rate: number): Promise { + return setCommissionRateInDb(rate); +} + +/** + * Get the affiliate minimum payout from app settings. + * Returns the amount in cents. + */ +export async function getAffiliateMinimumPayoutUseCase(): Promise { + return getMinimumPayoutFromDb(); +} + +/** + * Set the affiliate minimum payout in app settings. + * @param amount - Amount in cents (must be >= 0) + */ +export async function setAffiliateMinimumPayoutUseCase(amount: number): Promise { + return setMinimumPayoutInDb(amount); +} + +// ============================================ +// Pricing Use Cases +// ============================================ + +/** + * Get all pricing settings at once. + */ +export async function getPricingSettingsUseCase(): Promise<{ + currentPrice: number; + originalPrice: number; + promoLabel: string; +}> { + return getPricingSettingsFromDb(); +} + +/** + * Update pricing settings. + */ +export async function updatePricingSettingsUseCase(settings: { + currentPrice?: number; + originalPrice?: number; + promoLabel?: string; +}): Promise { + const updates: Promise[] = []; + + if (settings.currentPrice !== undefined) { + updates.push(setCurrentPriceInDb(settings.currentPrice)); + } + if (settings.originalPrice !== undefined) { + updates.push(setOriginalPriceInDb(settings.originalPrice)); + } + if (settings.promoLabel !== undefined) { + updates.push(setPromoLabelInDb(settings.promoLabel)); + } + + await Promise.all(updates); +} diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 00000000..8e19a3d1 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,147 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { env } from "~/utils/env"; + +/** + * Compares two strings in constant time to prevent timing attacks. + * Returns false if either string is null/undefined or if lengths differ. + * + * @param a - First string to compare + * @param b - Second string to compare + * @returns True if strings are equal + */ +export function timingSafeStringEqual( + a: string | null | undefined, + b: string | null | undefined +): boolean { + if (a == null || b == null) { + return false; + } + + // Convert to buffers with consistent encoding + const bufferA = Buffer.from(a, "utf8"); + const bufferB = Buffer.from(b, "utf8"); + + // timingSafeEqual requires equal-length buffers + if (bufferA.length !== bufferB.length) { + return false; + } + + return timingSafeEqual(bufferA, bufferB); +} + +/** + * Generates a cryptographically secure random state token for CSRF protection. + * + * This function creates a 64-character hexadecimal string using the Web Crypto API, + * suitable for use as a CSRF state parameter in OAuth flows. + * + * @returns A 64-character hexadecimal string + */ +export function generateCsrfState(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( + "" + ); +} + +/** + * Signs data with HMAC-SHA256 using a dedicated token signing secret. + * Used for creating secure, verifiable tokens (e.g., unsubscribe links). + * + * @param data - The data to sign + * @returns Base64url-encoded signature + */ +export function signData(data: string): string { + // Use dedicated TOKEN_SIGNING_SECRET for signing (independent of Stripe) + const secret = env.TOKEN_SIGNING_SECRET; + const hmac = createHmac("sha256", secret); + hmac.update(data); + return hmac.digest("base64url"); +} + +/** + * Verifies a signature against the original data. + * + * @param data - The original data that was signed + * @param signature - The signature to verify + * @returns True if the signature is valid + */ +export function verifySignature(data: string, signature: string): boolean { + const expectedSignature = signData(data); + // Use timing-safe comparison to prevent timing attacks + const sigBuffer = Buffer.from(signature, "base64url"); + const expectedBuffer = Buffer.from(expectedSignature, "base64url"); + + if (sigBuffer.length !== expectedBuffer.length) { + return false; + } + + return timingSafeEqual(sigBuffer, expectedBuffer); +} + +/** + * Creates a signed token containing userId and expiration timestamp. + * Format: base64url(userId:expiration):signature + * + * @param userId - The user ID to encode + * @param expirationDays - Number of days until token expires (default: 30) + * @returns Signed token string + */ +export function createSignedUnsubscribeToken( + userId: number, + expirationDays: number = 30 +): string { + const expiration = Date.now() + expirationDays * 24 * 60 * 60 * 1000; + const payload = `${userId}:${expiration}`; + const signature = signData(payload); + const encodedPayload = Buffer.from(payload).toString("base64url"); + return `${encodedPayload}.${signature}`; +} + +/** + * Verifies and decodes a signed unsubscribe token. + * + * @param token - The token to verify + * @returns The userId if valid and not expired, null otherwise + */ +export function verifyUnsubscribeToken(token: string): number | null { + const parts = token.split("."); + if (parts.length !== 2) { + return null; + } + + const [encodedPayload, signature] = parts; + let payload: string; + + try { + payload = Buffer.from(encodedPayload, "base64url").toString("utf8"); + } catch { + return null; + } + + // Verify signature + if (!verifySignature(payload, signature)) { + return null; + } + + // Parse payload + const payloadParts = payload.split(":"); + if (payloadParts.length !== 2) { + return null; + } + + const userId = parseInt(payloadParts[0], 10); + const expiration = parseInt(payloadParts[1], 10); + + if (isNaN(userId) || isNaN(expiration)) { + return null; + } + + // Check expiration + if (Date.now() > expiration) { + return null; + } + + return userId; +} diff --git a/src/utils/email.ts b/src/utils/email.ts index 8f48d7e2..47aaaacb 100644 --- a/src/utils/email.ts +++ b/src/utils/email.ts @@ -1,10 +1,13 @@ -import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses"; +import { SESClient, SendRawEmailCommand } from "@aws-sdk/client-ses"; import { render } from "@react-email/render"; import { marked } from "marked"; import { env } from "~/utils/env"; +import { createSignedUnsubscribeToken } from "~/utils/crypto"; import { CourseUpdateEmail } from "~/components/emails/course-update-email"; import { VideoNotificationEmail } from "~/components/emails/video-notification-email"; import { MultiSegmentNotificationEmail } from "~/components/emails/multi-segment-notification-email"; +import { AffiliatePayoutSuccessEmail } from "~/components/emails/affiliate-payout-success-email"; +import { AffiliatePayoutFailedEmail } from "~/components/emails/affiliate-payout-failed-email"; // Initialize SES client const sesClient = new SESClient({ @@ -20,6 +23,7 @@ export interface EmailOptions { subject: string; html: string; text?: string; + userId?: number; // Optional: if provided, adds List-Unsubscribe header } export interface EmailTemplate { @@ -28,30 +32,54 @@ export interface EmailTemplate { isMarketingEmail?: boolean; // Keep for backwards compatibility but always treated as true } -// Send email using AWS SES +/** + * Builds a raw MIME email message with proper headers including List-Unsubscribe. + */ +function buildRawEmail(options: EmailOptions): string { + const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`; + + // Build headers + const headers: string[] = [ + `From: ${env.FROM_EMAIL_ADDRESS}`, + `To: ${options.to}`, + `Subject: =?UTF-8?B?${Buffer.from(options.subject).toString("base64")}?=`, + `MIME-Version: 1.0`, + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ]; + + // Add List-Unsubscribe header if userId is provided (improves deliverability) + if (options.userId) { + const unsubscribeUrl = createUnsubscribeLink(options.userId); + headers.push(`List-Unsubscribe: <${unsubscribeUrl}>`); + headers.push(`List-Unsubscribe-Post: List-Unsubscribe=One-Click`); + } + + // Build body parts + const textPart = options.text || "Please view this email in an HTML-compatible email client."; + const parts: string[] = [ + `--${boundary}`, + `Content-Type: text/plain; charset=UTF-8`, + `Content-Transfer-Encoding: base64`, + ``, + Buffer.from(textPart).toString("base64"), + `--${boundary}`, + `Content-Type: text/html; charset=UTF-8`, + `Content-Transfer-Encoding: base64`, + ``, + Buffer.from(options.html).toString("base64"), + `--${boundary}--`, + ]; + + return headers.join("\r\n") + "\r\n\r\n" + parts.join("\r\n"); +} + +// Send email using AWS SES with List-Unsubscribe header support export async function sendEmail(options: EmailOptions): Promise { - const command = new SendEmailCommand({ - Source: env.FROM_EMAIL_ADDRESS, - Destination: { - ToAddresses: [options.to], - }, - Message: { - Subject: { - Data: options.subject, - Charset: "UTF-8", - }, - Body: { - Html: { - Data: options.html, - Charset: "UTF-8", - }, - ...(options.text && { - Text: { - Data: options.text, - Charset: "UTF-8", - }, - }), - }, + const rawMessage = buildRawEmail(options); + + const command = new SendRawEmailCommand({ + RawMessage: { + Data: Buffer.from(rawMessage), }, }); @@ -169,8 +197,16 @@ export async function checkEmailDeliveryHealth(): Promise<{ complaintRate: number; isHealthy: boolean; }> { - // In a real implementation, you would fetch SES statistics - // For now, return mock data + // In production, this should fetch real SES statistics + // TODO: Implement real SES stats fetching using GetSendStatisticsCommand + if (env.NODE_ENV === "production") { + throw new Error( + "checkEmailDeliveryHealth() is not implemented for production. " + + "Please implement real SES statistics fetching using GetSendStatisticsCommand." + ); + } + + // Development/test mock data return { bounceRate: 0.01, // 1% complaintRate: 0.001, // 0.1% @@ -178,10 +214,10 @@ export async function checkEmailDeliveryHealth(): Promise<{ }; } -// Create unsubscribe link +// Create unsubscribe link with cryptographically signed token export function createUnsubscribeLink(userId: number): string { - // In a real implementation, you would create a signed URL or token - return `${env.HOST_NAME}/unsubscribe?user=${userId}`; + const token = createSignedUnsubscribeToken(userId); + return `${env.HOST_NAME}/unsubscribe?token=${encodeURIComponent(token)}`; } // Validate email template before sending @@ -258,3 +294,64 @@ export async function renderMultiSegmentNotificationEmail( throw new Error(`Failed to render multi-segment notification email: ${error}`); } } + +// Render and send affiliate payout success email +export interface AffiliatePayoutSuccessEmailProps { + affiliateName: string; + payoutAmount: string; + payoutDate: string; + stripeTransferId: string; +} + +export async function sendAffiliatePayoutSuccessEmail( + to: string, + props: AffiliatePayoutSuccessEmailProps +): Promise { + try { + const html = await render(AffiliatePayoutSuccessEmail(props)); + const text = await render(AffiliatePayoutSuccessEmail(props), { + plainText: true, + }); + await sendEmail({ + to, + subject: `Your affiliate payout of ${props.payoutAmount} has been sent!`, + html, + text, + }); + console.log( + `[Affiliate Email] Sent payout success notification for ${props.stripeTransferId}` + ); + } catch (error) { + console.error("Failed to send affiliate payout success email:", error); + // Don't throw - email failures shouldn't break the payout flow + } +} + +// Render and send affiliate payout failed email +export interface AffiliatePayoutFailedEmailProps { + affiliateName: string; + errorMessage: string; + failureDate: string; +} + +export async function sendAffiliatePayoutFailedEmail( + to: string, + props: AffiliatePayoutFailedEmailProps +): Promise { + try { + const html = await render(AffiliatePayoutFailedEmail(props)); + const text = await render(AffiliatePayoutFailedEmail(props), { + plainText: true, + }); + await sendEmail({ + to, + subject: "Action required: Your affiliate payout could not be processed", + html, + text, + }); + console.log(`[Affiliate Email] Sent payout failure notification`); + } catch (error) { + console.error("Failed to send affiliate payout failed email:", error); + // Don't throw - email failures shouldn't break the webhook flow + } +} diff --git a/src/utils/env.ts b/src/utils/env.ts index 2079233b..749d495e 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -36,6 +36,12 @@ export const env = { STRIPE_PRICE_ID: testFallback(process.env.STRIPE_PRICE_ID, "price_test_placeholder", "STRIPE_PRICE_ID"), STRIPE_WEBHOOK_SECRET: testFallback(process.env.STRIPE_WEBHOOK_SECRET, "whsec_test_placeholder", "STRIPE_WEBHOOK_SECRET"), STRIPE_DISCOUNT_COUPON_ID: process.env.STRIPE_DISCOUNT_COUPON_ID, + // Dedicated secret for signing tokens (unsubscribe links, etc.) + // Independent of Stripe webhook secret for better security separation + TOKEN_SIGNING_SECRET: testFallback(process.env.TOKEN_SIGNING_SECRET, "test-token-signing-secret-32chars", "TOKEN_SIGNING_SECRET"), + // Stripe Connect OAuth Client ID (for connecting existing accounts) + // Get this from Stripe Dashboard > Settings > Connect > Platform settings + STRIPE_CLIENT_ID: process.env.STRIPE_CLIENT_ID, RECAPTCHA_SECRET_KEY: testFallback(process.env.RECAPTCHA_SECRET_KEY, "test-recaptcha-secret", "RECAPTCHA_SECRET_KEY"), MAILING_LIST_ENDPOINT: testFallback(process.env.MAILING_LIST_ENDPOINT, "https://test.example.com/mailing", "MAILING_LIST_ENDPOINT"), MAILING_LIST_PASSWORD: testFallback(process.env.MAILING_LIST_PASSWORD, "test-mailing-password", "MAILING_LIST_PASSWORD"), diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..52695072 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,39 @@ +/** + * Structured logger for production-ready logging. + * Outputs JSON format with timestamp, level, and context for easy parsing. + */ + +type LogContext = Record; + +export const logger = { + info: (message: string, context?: LogContext) => { + console.log( + JSON.stringify({ + level: "info", + timestamp: new Date().toISOString(), + message, + ...context, + }) + ); + }, + warn: (message: string, context?: LogContext) => { + console.warn( + JSON.stringify({ + level: "warn", + timestamp: new Date().toISOString(), + message, + ...context, + }) + ); + }, + error: (message: string, context?: LogContext) => { + console.error( + JSON.stringify({ + level: "error", + timestamp: new Date().toISOString(), + message, + ...context, + }) + ); + }, +}; diff --git a/src/utils/stripe-status.ts b/src/utils/stripe-status.ts new file mode 100644 index 00000000..a0e49a98 --- /dev/null +++ b/src/utils/stripe-status.ts @@ -0,0 +1,39 @@ +import type Stripe from "stripe"; + +export const StripeAccountStatus = { + NOT_STARTED: "not_started", + ONBOARDING: "onboarding", + ACTIVE: "active", + RESTRICTED: "restricted", +} as const; + +export type StripeAccountStatusType = typeof StripeAccountStatus[keyof typeof StripeAccountStatus]; + +/** + * Determines the status of a Stripe Connect account based on its properties. + * + * @param account - The Stripe account object + * @returns The account status + * + * Status flow: + * - not_started: No account created yet (default in database) + * - onboarding: Account created but details not submitted or not yet fully activated + * - restricted: Account has restrictions or disabled_reason + * - active: Fully activated with charges and payouts enabled + */ +export function determineStripeAccountStatus(account: Stripe.Account): StripeAccountStatusType { + if (!account.details_submitted) { + return StripeAccountStatus.ONBOARDING; + } + + if (account.requirements?.disabled_reason) { + return StripeAccountStatus.RESTRICTED; + } + + if (account.charges_enabled && account.payouts_enabled) { + return StripeAccountStatus.ACTIVE; + } + + // Details submitted but not fully activated yet - still onboarding + return StripeAccountStatus.ONBOARDING; +} diff --git a/src/utils/url-sanitizer.ts b/src/utils/url-sanitizer.ts new file mode 100644 index 00000000..077b8b5b --- /dev/null +++ b/src/utils/url-sanitizer.ts @@ -0,0 +1,36 @@ +/** + * Validates and sanitizes a URL for safe use in img src attributes. + * Prevents XSS via javascript: or data: protocols. + * + * @param url - The URL to validate + * @returns The original URL if safe, null if unsafe or invalid + */ +export function sanitizeImageUrl(url: string | null | undefined): string | null { + if (!url) { + return null; + } + + try { + const parsed = new URL(url); + + // Only allow http and https protocols + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return null; + } + + return url; + } catch { + // Invalid URL + return null; + } +} + +/** + * Checks if a URL is safe for use in img src attributes. + * + * @param url - The URL to check + * @returns True if the URL is safe + */ +export function isValidImageUrl(url: string | null | undefined): boolean { + return sanitizeImageUrl(url) !== null; +} diff --git a/vite.config.ts b/vite.config.ts index 8a54693f..825805c9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,12 @@ import { nitro } from "nitro/vite"; const DEFAULT_PORT = 4000; export default defineConfig({ - server: { port: parseInt(process.env.PORT || DEFAULT_PORT.toString(), 10) }, + server: { + port: parseInt(process.env.PORT || DEFAULT_PORT.toString(), 10), + watch: { + ignored: ["**/routeTree.gen.ts"], + }, + }, ssr: { noExternal: ["react-dropzone"] }, plugins: [ tailwindcss(),