From d35f5d20f1e65aa2a6155daa44f5193c8be0d693 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sun, 21 Dec 2025 03:31:31 +0000 Subject: [PATCH 01/28] fix: Generate presigned URLs on-demand for profile images - Generate fresh presigned URLs in data-access layer instead of storing - Profile images now persist indefinitely (no 1-hour expiry) - Upload only stores imageId (R2 key), not the presigned URL From 5af31f60b6bcd9ecc8d19ad9633716bcf8583f7f Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Thu, 25 Dec 2025 03:42:05 +0000 Subject: [PATCH 02/28] ci: Add workflow_dispatch to enable manual test runs --- src/features/affiliates/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/features/affiliates/README.md diff --git a/src/features/affiliates/README.md b/src/features/affiliates/README.md new file mode 100644 index 00000000..eb9afaed --- /dev/null +++ b/src/features/affiliates/README.md @@ -0,0 +1 @@ +# Affiliates Feature From 444de35ad183ec7b8ac0a44023d904bcf7178922 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Fri, 26 Dec 2025 21:52:54 +0000 Subject: [PATCH 03/28] feat(admin): Add pricing settings page and NumberInputWithControls component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /admin/pricing page for managing course pricing - Create reusable NumberInputWithControls component with +/- buttons - Add Pricing link to admin navigation - Fix pricing display: promo discount as main, affiliate as extra - Update purchase page to show correct discount hierarchy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/features/affiliates/readme.md | 233 +++++ package.json | 17 +- pnpm-lock.yaml | 238 ++++++ .../blocks/number-input-with-controls.tsx | 130 +++ .../data-table/data-table-column-header.tsx | 103 +++ .../data-table/data-table-date-filter.tsx | 225 +++++ .../data-table/data-table-faceted-filter.tsx | 189 ++++ .../data-table/data-table-pagination.tsx | 120 +++ .../data-table/data-table-skeleton.tsx | 115 +++ .../data-table/data-table-slider-filter.tsx | 256 ++++++ .../data-table/data-table-toolbar.tsx | 149 ++++ .../data-table/data-table-view-options.tsx | 89 ++ src/components/data-table/data-table.tsx | 101 +++ .../emails/affiliate-payout-failed-email.tsx | 236 +++++ .../emails/affiliate-payout-success-email.tsx | 199 +++++ src/components/ui/button-group.tsx | 76 ++ src/components/ui/button.tsx | 85 +- src/components/ui/calendar.tsx | 218 +++++ src/components/ui/collapsible.tsx | 31 + src/components/ui/radio-group.tsx | 43 + src/components/ui/slider.tsx | 61 ++ src/config/data-table.ts | 82 ++ src/data-access/affiliates.ts | 498 ++++++++++- src/data-access/app-settings.ts | 169 +++- src/db/schema.ts | 33 +- src/fn/affiliates.ts | 113 ++- src/fn/app-settings.ts | 79 ++ src/hooks/use-callback-ref.ts | 27 + src/hooks/use-data-table.ts | 317 +++++++ src/hooks/use-debounced-callback.ts | 28 + src/lib/data-table.ts | 82 ++ src/lib/format.ts | 17 + src/lib/parsers.ts | 99 +++ .../affiliate-details-sheet.tsx | 435 ++++++++++ .../affiliates-columns.tsx | 292 +++++++ src/routes/admin/-components/admin-nav.tsx | 7 + src/routes/admin/affiliates.tsx | 805 +++++++++++------- src/routes/admin/pricing.tsx | 287 +++++++ src/routes/admin/route.tsx | 2 +- src/routes/affiliate-dashboard.tsx | 479 ++++++++++- src/routes/affiliates.tsx | 360 ++++---- .../api/connect/stripe/callback/index.ts | 106 +++ src/routes/api/connect/stripe/index.ts | 127 +++ .../api/connect/stripe/refresh/index.ts | 110 +++ src/routes/api/login/google/callback/index.ts | 6 +- src/routes/api/stripe/webhook.ts | 517 ++++++++--- src/routes/purchase.tsx | 231 ++++- src/types/data-table.ts | 53 ++ src/use-cases/affiliates.ts | 646 ++++++++++++-- src/use-cases/app-settings.ts | 78 ++ src/utils/crypto.ts | 15 + src/utils/email.ts | 55 ++ src/utils/logger.ts | 39 + src/utils/stripe-status.ts | 40 + 54 files changed, 8275 insertions(+), 873 deletions(-) create mode 100644 src/components/blocks/number-input-with-controls.tsx create mode 100644 src/components/data-table/data-table-column-header.tsx create mode 100644 src/components/data-table/data-table-date-filter.tsx create mode 100644 src/components/data-table/data-table-faceted-filter.tsx create mode 100644 src/components/data-table/data-table-pagination.tsx create mode 100644 src/components/data-table/data-table-skeleton.tsx create mode 100644 src/components/data-table/data-table-slider-filter.tsx create mode 100644 src/components/data-table/data-table-toolbar.tsx create mode 100644 src/components/data-table/data-table-view-options.tsx create mode 100644 src/components/data-table/data-table.tsx create mode 100644 src/components/emails/affiliate-payout-failed-email.tsx create mode 100644 src/components/emails/affiliate-payout-success-email.tsx create mode 100644 src/components/ui/button-group.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/config/data-table.ts create mode 100644 src/hooks/use-callback-ref.ts create mode 100644 src/hooks/use-data-table.ts create mode 100644 src/hooks/use-debounced-callback.ts create mode 100644 src/lib/data-table.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/parsers.ts create mode 100644 src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx create mode 100644 src/routes/admin/-affiliates-components/affiliates-columns.tsx create mode 100644 src/routes/admin/pricing.tsx create mode 100644 src/routes/api/connect/stripe/callback/index.ts create mode 100644 src/routes/api/connect/stripe/index.ts create mode 100644 src/routes/api/connect/stripe/refresh/index.ts create mode 100644 src/types/data-table.ts create mode 100644 src/utils/crypto.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/stripe-status.ts diff --git a/docs/features/affiliates/readme.md b/docs/features/affiliates/readme.md index 81b4a582..f2471c37 100644 --- a/docs/features/affiliates/readme.md +++ b/docs/features/affiliates/readme.md @@ -400,6 +400,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 unpaid balance reaches $50, admin can trigger automatic payout +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. **Minimum balance** - Unpaid balance must be at least $50 (5000 cents) + +#### 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**: Affiliate's unpaid balance is less than $50. + +**Solution**: Wait for more referral conversions until balance reaches $50. + +#### "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/package.json b/package.json index d64743dd..b71a72cb 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", @@ -63,10 +73,11 @@ "@stripe/stripe-js": "^7.6.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.83.0", - "@tanstack/react-query-devtools": "^5.83.0", + "@tanstack/react-query-devtools": "^5.83.0", "@tanstack/react-router": "^1.143.3", "@tanstack/react-router-with-query": "^1.130.17", - "@tanstack/react-start": "^1.143.3", + "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5c485c..4756a31e 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) @@ -719,6 +737,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 +1613,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 +1858,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 +1910,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 +1936,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 +1975,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 +2779,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 +3029,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 +3110,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 +3658,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 +4814,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 +5067,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 +7056,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 +7676,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 +7935,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 +7973,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 +8008,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 +8063,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 +9033,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 +9273,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 +9424,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 +10045,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 +11333,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 +11602,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..dc64c5d6 --- /dev/null +++ b/src/components/data-table/data-table-date-filter.tsx @@ -0,0 +1,225 @@ +"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..a35f1eb4 --- /dev/null +++ b/src/components/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,189 @@ +"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 = new Set( + Array.isArray(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..4ae343a5 --- /dev/null +++ b/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,120 @@ +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

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} 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..67bd21ea --- /dev/null +++ b/src/components/data-table/data-table-slider-filter.tsx @@ -0,0 +1,256 @@ +"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) => { + if (event.target instanceof HTMLDivElement) { + 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..1fe5273e --- /dev/null +++ b/src/components/data-table/data-table-toolbar.tsx @@ -0,0 +1,149 @@ +"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; + + const onFilterRender = React.useCallback(() => { + 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; + } + }, [column, columnMeta]); + + return onFilterRender(); + } +} 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/emails/affiliate-payout-failed-email.tsx b/src/components/emails/affiliate-payout-failed-email.tsx new file mode 100644 index 00000000..7798142f --- /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 errorMessage = { + 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/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..e4c10d8d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,59 +1,62 @@ -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]", - cyan: "btn-cyan text-white shadow-lg shadow-cyan-500/20", + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", }, 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 +
+ + + { + const val = e.target.value; + if (val === "" || /^\d+$/.test(val)) { + let numVal = val === "" ? 0 : parseInt(val, 10); + if (numVal > 100) numVal = 100; + if (numVal < 0) numVal = 0; + setNewCommissionRate(numVal.toString()); + if (!editingRate) setEditingRate(true); + } + }} + className="text-center text-sm h-8" + /> + % + + + + + +
+ + {/* 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 */} +
+ + {/* Timeline items */} +
+ {affiliate.lastReferralDate && ( +
+
+
+

New referral converted

+

{formatDate(affiliate.lastReferralDate)}

+
+
+ )} + + {affiliate.paidAmount > 0 && ( +
+
+
+

Payout processed

+

{formatCurrency(affiliate.paidAmount)} total paid

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

Stripe account connected

+

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

+
+
+ )} + +
+
+
+

Partner account created

+

{formatDate(affiliate.createdAt)}

+
+
+
+
+
+ + + ); +} 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..2f20b845 --- /dev/null +++ b/src/routes/admin/-affiliates-components/affiliates-columns.tsx @@ -0,0 +1,292 @@ +"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"; + +export type AffiliateRow = { + id: number; + userId: number; + userEmail: string | null; + userName: 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 */} +
+
+ {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..4bd691d3 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 { diff --git a/src/routes/admin/affiliates.tsx b/src/routes/admin/affiliates.tsx index 85d05639..0b6d35a6 100644 --- a/src/routes/admin/affiliates.tsx +++ b/src/routes/admin/affiliates.tsx @@ -1,10 +1,9 @@ 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 { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Textarea } from "~/components/ui/textarea"; - import { Dialog, DialogContent, @@ -27,61 +26,53 @@ 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, + Minus, + Plus, + Save, + Loader2, } from "lucide-react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; + ButtonGroup, + InputGroup, + InputGroupInput, + InputGroupAddon, +} from "~/components/ui/button-group"; 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"; // Skeleton components function CountSkeleton() { - return
; -} - -function AffiliateCardSkeleton() { - return ( -
-
-
-
-
-
-
-
- {[...Array(4)].map((_, i) => ( -
-
-
-
- ))} -
-
-
-
-
-
-
- ); + return
; } const payoutSchema = z.object({ @@ -93,12 +84,142 @@ 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 } = 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")({ 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,12 +231,58 @@ function AdminAffiliates() { ); const [payoutAffiliateName, setPayoutAffiliateName] = useState(""); const [payoutUnpaidBalance, setPayoutUnpaidBalance] = useState(0); + 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: affiliates, isLoading } = useQuery({ queryKey: ["admin", "affiliates"], queryFn: () => adminGetAllAffiliatesFn(), }); + // 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), defaultValues: { @@ -158,6 +325,21 @@ function AdminAffiliates() { }, }); + const autoPayoutMutation = useMutation({ + mutationFn: adminProcessAutomaticPayoutsFn, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ["admin", "affiliates"] }); + toast.success("Auto-Payouts Triggered", { + description: `Processed ${result.processed} affiliates: ${result.successful} successful, ${result.failed} failed`, + }); + }, + onError: (error) => { + toast.error("Auto-Payout Failed", { + description: error.message || "Failed to trigger automatic payouts.", + }); + }, + }); + const handleToggleStatus = async ( affiliateId: number, currentStatus: boolean @@ -167,13 +349,17 @@ function AdminAffiliates() { }); }; - const openPayoutDialog = (affiliate: any) => { + const handleTriggerAutoPayouts = async () => { + await autoPayoutMutation.mutateAsync(); + }; + + const openPayoutDialog = (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); }; const onSubmitPayout = async (values: PayoutFormValues) => { @@ -182,7 +368,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,15 +383,6 @@ 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) => { navigator.clipboard.writeText(text); toast.success("Copied!", { @@ -224,6 +401,36 @@ function AdminAffiliates() { { totalUnpaid: 0, totalPaid: 0, totalEarnings: 0, activeCount: 0 } ) || { totalUnpaid: 0, totalPaid: 0, totalEarnings: 0, activeCount: 0 }; + // Columns for DataTable + const columns = useMemo( + () => + getAffiliateColumns({ + onCopyLink: copyToClipboard, + onViewLink: (link) => window.open(link, "_blank"), + onRecordPayout: openPayoutDialog, + onToggleStatus: handleToggleStatus, + onViewDetails: (affiliate) => { + setSelectedAffiliate(affiliate); + setDetailsSheetOpen(true); + }, + }), + [] + ); + + // 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: "balance", 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 */} + + + Min + $ + { + const val = e.target.value; + if (val === "" || /^\d+$/.test(val)) { + let numVal = val === "" ? 0 : parseInt(val, 10); + if (numVal < 0) numVal = 0; + minimumPayoutState.handleAmountChange(numVal.toString()); + } + }} + disabled={ + minimumPayoutState.isLoading || minimumPayoutState.isPending + } + className="text-center text-sm h-10" + /> + + + + + + + {/* Default Multiplier */} + + + Rate + { + const val = e.target.value; + if (val === "" || /^\d+$/.test(val)) { + let numVal = val === "" ? 0 : parseInt(val, 10); + if (numVal > 100) numVal = 100; + if (numVal < 0) numVal = 0; + commissionRateState.handleRateChange(numVal.toString()); + } + }} + disabled={ + commissionRateState.isLoading || commissionRateState.isPending + } + className="text-center text-sm h-10" + /> + % + + + + + + + {/* Batch Settle Button */} + +
+
+ + {/* Stats Cards */} +
+ {/* Escrow/Unpaid */} +
+
+ + Escrow / Unpaid + +
+
-

Pending payouts

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

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 -
-
- -
-
-
- {isLoading ? : totals.activeCount} + {/* Active Nodes */} +
+
+ + Active Nodes + +
+
-

- 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 */} !open && setPayoutAffiliateId(null)} @@ -636,7 +805,7 @@ function AdminAffiliates() { > {recordPayoutMutation.isPending ? (
-
+
Recording...
) : ( 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/affiliate-dashboard.tsx b/src/routes/affiliate-dashboard.tsx index 32cd947d..d3fefca6 100644 --- a/src/routes/affiliate-dashboard.tsx +++ b/src/routes/affiliate-dashboard.tsx @@ -22,6 +22,8 @@ import { getAffiliateDashboardFn, updateAffiliatePaymentLinkFn, checkIfUserIsAffiliateFn, + refreshStripeAccountStatusFn, + disconnectStripeAccountFn, } from "~/fn/affiliates"; import { authenticatedMiddleware } from "~/lib/auth"; import { @@ -38,6 +40,15 @@ import { CreditCard, ArrowUpRight, ArrowDownRight, + CheckCircle, + XCircle, + AlertCircle, + AlertTriangle, + RefreshCw, + Clock, + Link as LinkIcon, + Zap, + Unlink, } from "lucide-react"; import { cn } from "~/lib/utils"; import { env } from "~/utils/env"; @@ -49,6 +60,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"; @@ -64,12 +86,30 @@ import { import { publicEnv } from "~/utils/env-public"; import { assertAuthenticatedFn } from "~/fn/auth"; import { motion } from "framer-motion"; +import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; -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 = { @@ -163,34 +203,69 @@ function AffiliateDashboard() { const queryClient = useQueryClient(); const [copied, setCopied] = useState(false); const [editPaymentOpen, setEditPaymentOpen] = useState(false); + const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false); // Use the dashboard data from loader const dashboard = loaderData.dashboard; - 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 onSubmitPaymentLink = async (values: PaymentLinkFormValues) => { + const onSubmitPaymentForm = async (values: PaymentFormValues) => { await updatePaymentMutation.mutateAsync({ data: values }); }; @@ -312,6 +387,243 @@ function AffiliateDashboard() { + {/* Stripe Connect Status Card - Only show for Stripe payment method */} + {dashboard.affiliate.paymentMethod === "stripe" && ( + + + {/* Glow effect on hover */} +
+ +
+
+ + + Stripe Connect + + + Automatic payouts via Stripe Connect + +
+
+ {(dashboard.affiliate.stripeAccountStatus === "onboarding" || + dashboard.affiliate.stripeAccountStatus === "active" || + dashboard.affiliate.stripeAccountStatus === "restricted") && ( + + )} + {dashboard.affiliate.stripeAccountStatus === "active" && dashboard.affiliate.stripePayoutsEnabled && ( + + + Connected + + )} +
+
+
+ + {/* 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. +

+
+
+
+ )} + + {/* Account Status Display */} + {dashboard.affiliate.stripeAccountStatus === "not_started" && ( +
+
+
+ +
+
+

Connect Your Stripe Account

+

+ Set up Stripe Connect to receive automatic payouts when you reach $50 +

+
+
+ + + Connect Stripe Account + +
+ )} + + {dashboard.affiliate.stripeAccountStatus === "onboarding" && ( +
+
+
+ +
+
+

Complete Your Onboarding

+

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

+
+
+ + + Complete Onboarding + +
+ )} + + {(dashboard.affiliate.stripeAccountStatus === "active" || dashboard.affiliate.stripeAccountStatus === "restricted") && ( +
+ {/* Status Badges */} +
+
+ {dashboard.affiliate.stripePayoutsEnabled ? ( + + ) : ( + + )} + Payouts {dashboard.affiliate.stripePayoutsEnabled ? "Enabled" : "Disabled"} +
+
+ {dashboard.affiliate.stripeChargesEnabled ? ( + + ) : ( + + )} + Charges {dashboard.affiliate.stripeChargesEnabled ? "Enabled" : "Disabled"} +
+ {dashboard.affiliate.stripeDetailsSubmitted && ( +
+ + Details Submitted +
+ )} +
+ + {/* Restricted Account Warning */} + {dashboard.affiliate.stripeAccountStatus === "restricted" && ( +
+
+ +

Account Restricted

+
+

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

+
+ )} + + {/* Auto-Payout Info */} + {dashboard.affiliate.stripePayoutsEnabled && ( +
+
+ +

Automatic Payouts Active

+
+

+ Payouts are automatically processed when your balance reaches $50 +

+
+ )} + + {/* Last Sync Time */} + {dashboard.affiliate.lastStripeSync && ( +
+ + Last synced: {formatDate(dashboard.affiliate.lastStripeSync)} +
+ )} + + {/* Disconnect Stripe Button */} +
+ + + + + + + Disconnect Stripe Connect? + + This will disconnect your Stripe Connect account and switch you back to manual payouts via a payment link. You can reconnect Stripe Connect at any time. + + + + Cancel + disconnectStripeAccountMutation.mutate({ data: {} })} + disabled={disconnectStripeAccountMutation.isPending} + className="bg-red-600 text-white hover:bg-red-700" + > + {disconnectStripeAccountMutation.isPending ? "Disconnecting..." : "Disconnect"} + + + + +
+
+ )} +
+
+
+ )} + {/* Stats Grid */} - 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 - + + + + - - PayPal, Venmo, or other payment link - - )} /> + + {paymentMethod === "link" ? ( + ( + + Payment Link + + + + + PayPal, Venmo, or other payment link + + + + )} + /> + ) : ( +
+
+ +

Stripe Connect

+
+

+ Automatic payouts will be enabled once Stripe Connect is configured +

+
+ )} + @@ -493,25 +861,48 @@ function AffiliateDashboard() {
- -
- - Minimum Payout:{" "} - - $50.00 +
+
+ + Payment Method: + + + {dashboard.affiliate.paymentMethod === "stripe" + ? "Stripe Connect" + : "Payment Link"} + +
+ + {dashboard.affiliate.paymentMethod === "link" && dashboard.affiliate.paymentLink ? ( + + ) : dashboard.affiliate.paymentMethod === "stripe" ? ( +
+

+ + Stripe Connect integration pending - automatic payouts will be enabled once configured +

+
+ ) : null} + +
+ + Minimum Payout:{" "} + + $50.00 +
diff --git a/src/routes/affiliates.tsx b/src/routes/affiliates.tsx index 7faa10f7..82d6fe02 100644 --- a/src/routes/affiliates.tsx +++ b/src/routes/affiliates.tsx @@ -3,7 +3,6 @@ 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 { assertFeatureEnabled } from "~/lib/feature-flags"; import { Button, buttonVariants } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; @@ -25,6 +24,7 @@ import { FormLabel, FormMessage, } from "~/components/ui/form"; +import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; import { toast } from "sonner"; import { useAuth } from "~/hooks/use-auth"; import { registerAffiliateFn, checkIfUserIsAffiliateFn } from "~/fn/affiliates"; @@ -40,14 +40,32 @@ import { ArrowRight, Zap, BarChart3, + CreditCard, } from "lucide-react"; import { cn } from "~/lib/utils"; const affiliateFormSchema = z.object({ - paymentLink: z.url("Please provide a valid URL"), + paymentMethod: z.enum(["link", "stripe"]), + paymentLink: z.string().optional(), agreedToTerms: z.boolean().refine((val) => val === true, { message: "You must agree to the terms of service", }), +}).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 AffiliateFormValues = z.infer; @@ -76,25 +94,25 @@ function AffiliatesPage() { const form = useForm({ resolver: zodResolver(affiliateFormSchema), defaultValues: { + paymentMethod: "link", paymentLink: "", agreedToTerms: false, }, }); + const paymentMethod = form.watch("paymentMethod"); + 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.", + 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.", + description: error.message || "Failed to register as an affiliate. Please try again.", }); }, }); @@ -105,93 +123,17 @@ function AffiliatesPage() { 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 +
); } @@ -235,7 +177,7 @@ function AffiliatesPage() {
- Earn {AFFILIATE_CONFIG.COMMISSION_RATE}% Commission + Earn 30% Commission

@@ -254,7 +196,7 @@ function AffiliatesPage() {
-

{AFFILIATE_CONFIG.COMMISSION_RATE}% Commission

+

30% Commission

Earn $60 for every sale you refer. One of the highest commission rates in the industry. @@ -372,28 +314,95 @@ function AffiliatesPage() { onSubmit={form.handleSubmit(onSubmit)} className="space-y-6" > + {/* Payment Method Selection */} ( - Payment Link + How would you like to receive payouts? - + + + + - - Enter your PayPal, Venmo, or other payment link where - you'd like to receive affiliate payouts. - - )} /> + {/* Conditional UI based on payment method */} + {paymentMethod === "link" ? ( + ( + + Payment Link + + + + + Enter your PayPal, Venmo, or other payment link. + + + + )} + /> + ) : ( +

+
+ +

Stripe Connect

+
+

+ After joining, you'll be able to connect your Stripe account from the dashboard + to receive payouts directly to your bank account. +

+
+
+ +

Automatic Payouts

+
+

+ When your balance reaches $50, payouts are automatically processed to your connected Stripe account - no manual requests needed! +

+
+
+ )} + - +
{/* Decorative background elements */}
- +
{/* Badge */} @@ -437,158 +443,120 @@ function AffiliatesPage() { Legal Agreement
- - + + Affiliate Program Terms of Service - +

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

- +
- - 1 - + 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. + Affiliates earn 30% commission on all referred sales. Commissions are calculated + based on the net sale price after any discounts.

- - 2 - + 2

Payment Terms

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

- - 3 - + 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. + 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 - + 4

Prohibited Activities

- The following activities are strictly - prohibited: + 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 -
  • +
  • Misleading or false advertising
  • +
  • Self-referrals or fraudulent purchases
  • +
  • Trademark or brand misrepresentation
  • +
  • Paid search advertising on trademarked terms
- - 5 - + 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. + 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 - + 6

Modifications

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

- - 7 - + 7

Liability

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

@@ -622,6 +590,36 @@ function AffiliatesPage() {
+ + {/* Success Metrics */} +
+

+ Why Partners Love Our Program +

+
+
+
+ + 12% +
+

Average conversion rate

+
+
+
+ + $60 +
+

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..5e6c55fa --- /dev/null +++ b/src/routes/api/connect/stripe/callback/index.ts @@ -0,0 +1,106 @@ +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"; + +const AFTER_CONNECT_URL = "/affiliate-dashboard"; + +/** + * 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; + + // Validate CSRF state token + if (!state || !storedState || state !== storedState) { + return new Response("Invalid state parameter", { status: 400 }); + } + + // Clear 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 }); + } + + // Verify affiliate ID matches (extra security) + if (storedAffiliateId && String(affiliate.id) !== storedAffiliateId) { + return new Response("Affiliate mismatch", { status: 403 }); + } + + if (!affiliate.stripeConnectAccountId) { + 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, + lastStripeSync: new Date(), + }); + + // Redirect to affiliate dashboard + return new Response(null, { + status: 302, + headers: { Location: AFTER_CONNECT_URL }, + }); + } catch (error) { + console.error("Stripe Connect callback error:", error); + 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..8ad85aef --- /dev/null +++ b/src/routes/api/connect/stripe/index.ts @@ -0,0 +1,127 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { setCookie } from "@tanstack/react-start/server"; +import { stripe } from "~/lib/stripe"; +import { assertAuthenticated } from "~/utils/session"; +import { + getAffiliateByUserId, + updateAffiliateStripeAccount, + isConnectAttemptRateLimited, + recordConnectAttempt, +} 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 + +/** + * 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 (3 attempts per hour) + // const isRateLimited = await isConnectAttemptRateLimited(affiliate.id); + // if (isRateLimited) { + // return new Response( + // "Too many Stripe Connect attempts. Please try again later.", + // { status: 429 } + // ); + // } + + // Record this attempt for rate limiting + await recordConnectAttempt(affiliate.id); + + // Generate CSRF state token + const state = generateCsrfState(); + + // Store state in HTTP-only cookie for CSRF protection + // 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", state, { + 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, + }); + + 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 Response.redirect(accountLink.url); + } 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/refresh/index.ts b/src/routes/api/connect/stripe/refresh/index.ts new file mode 100644 index 00000000..b7ca2e1d --- /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 } 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 || 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..8b6e32ca 100644 --- a/src/routes/api/stripe/webhook.ts +++ b/src/routes/api/stripe/webhook.ts @@ -1,9 +1,50 @@ import { createFileRoute } from "@tanstack/react-router"; import { stripe } from "~/lib/stripe"; import { updateUserToPremiumUseCase } from "~/use-cases/users"; -import { processAffiliateReferralUseCase } from "~/use-cases/affiliates"; +import { + processAffiliateReferralUseCase, + processAutomaticPayoutsUseCase, + syncStripeAccountStatusUseCase, +} from "~/use-cases/affiliates"; +import { + getAffiliateByCode, + getPayoutByStripeTransferId, + getAffiliateByStripeAccountIdWithUserEmail, + updateAffiliatePayoutError, + updateLastPayoutAttempt, +} from "~/data-access/affiliates"; import { env } from "~/utils/env"; import { trackAnalyticsEvent } from "~/data-access/analytics"; +import { AFFILIATE_CONFIG } from "~/config"; +import { sendAffiliatePayoutFailedEmail } from "~/utils/email"; +import { logger } from "~/utils/logger"; + +// System user ID for automatic payouts (configured via environment variable) +const SYSTEM_USER_ID = env.SYSTEM_USER_ID; + +// Cooldown period for automatic payouts to prevent duplicate processing from webhook replays (60 seconds) +const PAYOUT_COOLDOWN_MS = 60000; + +// 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"; + } + + return "A payout error occurred. Please check your Stripe dashboard or contact support."; +} const webhookSecret = env.STRIPE_WEBHOOK_SECRET!; @@ -11,162 +52,360 @@ 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) { - console.error("Missing stripe-signature header"); - return new Response( - JSON.stringify({ error: "Missing stripe-signature header" }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - } - ); - } + 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" }, + } + ); + } - 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" }, - } - ); - } + const payload = await request.text(); - 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, - }, - }); - 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.` - ); + try { + const event = stripe.webhooks.constructEvent( + payload, + sig, + webhookSecret + ); + + 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; + + 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 { + const referral = await processAffiliateReferralUseCase({ + affiliateCode, + purchaserId: parseInt(userId), + stripeSessionId: session.id, + amount: session.amount_total, + }); - // Process affiliate referral if code exists - if (affiliateCode && session.amount_total) { - try { - const referral = await processAffiliateReferralUseCase({ + if (referral) { + logger.info("Successfully processed affiliate referral", { + fn: "stripe-webhook", affiliateCode, - purchaserId: parseInt(userId), - stripeSessionId: session.id, - amount: session.amount_total, + sessionId: session.id, + commission: referral.commission / 100, }); - 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` - ); - } - } 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, + // Trigger automatic payout if affiliate is eligible + // Check if balance >= minimum and Stripe Connect is enabled + try { + const affiliate = await getAffiliateByCode(affiliateCode); + if ( + affiliate && + affiliate.stripePayoutsEnabled && + affiliate.unpaidBalance >= AFFILIATE_CONFIG.MINIMUM_PAYOUT + ) { + // Check for recent payout attempt (cooldown protection against webhook replays) + let shouldSkipPayout = false; + if (affiliate.lastPayoutAttemptAt) { + const timeSinceLastAttempt = Date.now() - affiliate.lastPayoutAttemptAt.getTime(); + if (timeSinceLastAttempt < PAYOUT_COOLDOWN_MS) { + logger.info("Skipping payout: cooldown active", { + fn: "stripe-webhook", + affiliateId: affiliate.id, + timeSinceLastAttempt, + }); + shouldSkipPayout = true; + } + } + + if (!shouldSkipPayout) { + // Update timestamp before attempting payout + await updateLastPayoutAttempt(affiliate.id); + + logger.info("Triggering automatic payout", { + fn: "stripe-webhook", + affiliateId: affiliate.id, + balance: affiliate.unpaidBalance / 100, + }); + const payoutResult = await processAutomaticPayoutsUseCase({ + affiliateId: affiliate.id, + systemUserId: SYSTEM_USER_ID, + }); + if (payoutResult.success) { + logger.info("Automatic payout successful", { + fn: "stripe-webhook", + affiliateId: affiliate.id, + amount: (payoutResult.amount ?? 0) / 100, + transferId: payoutResult.transferId, + }); + } else { + logger.warn("Automatic payout skipped", { + fn: "stripe-webhook", + affiliateId: affiliate.id, + error: payoutResult.error, + }); + } + } } - ); - // Don't fail the webhook for affiliate errors - user upgrade should succeed + } catch (payoutError) { + logger.error("Failed to process automatic payout for affiliate after referral", { + fn: "stripe-webhook", + error: payoutError instanceof Error ? payoutError.message : String(payoutError), + }); + // Don't fail webhook for payout errors + } + } else { + logger.warn("Affiliate referral not processed", { + fn: "stripe-webhook", + affiliateCode, + sessionId: session.id, + reason: "likely duplicate, self-referral, or invalid code", + }); } + } 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 } } + } + + 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, + }); - console.log("Payment successful:", session.id); - break; + // 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; } - return new Response(JSON.stringify({ received: true }), { - 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" }, + default: { + // Handle transfer.paid, transfer.failed and other events that may not be in the type definitions + const eventType = event.type as string; + + // Handle transfer.paid - confirms funds have arrived at destination + if (eventType === "transfer.paid") { + const transfer = (event as unknown as { data: { object: { id: string; amount: number; destination: string; metadata?: Record } } }).data.object; + + 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 as unknown as { data: { object: { id: string; amount: number; destination: string; failure_message?: string; metadata?: Record } } }).data.object; + 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 = transfer.failure_message || "Transfer failed - unknown reason"; + // Convert to user-friendly message for storage and emails + const userFriendlyError = getUserFriendlyPayoutError(transfer.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, + }); + + // 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) { + logger.error("Error saving payout error to affiliate", { + fn: "stripe-webhook", + error: error instanceof Error ? error.message : String(error), + }); + } + + // 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 }), { + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + logger.error("Webhook Error", { + fn: "stripe-webhook", + error: err instanceof Error ? err.message : String(err), + }); + return new Response( + JSON.stringify({ error: "Webhook handler failed" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } }, }, }, diff --git a/src/routes/purchase.tsx b/src/routes/purchase.tsx index 1f64b222..5a2a1a37 100644 --- a/src/routes/purchase.tsx +++ b/src/routes/purchase.tsx @@ -30,18 +30,59 @@ 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"; 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, }); @@ -64,8 +105,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 +166,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 * 100; + let finalPriceInCents = basePriceInCents; + + // Apply affiliate discount if set + if (affiliateDiscount > 0) { + finalPriceInCents = Math.round(basePriceInCents * (1 - affiliateDiscount / 100)); + } + + const sessionConfig: Parameters[0] = { 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 +201,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) / 100); + 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 +267,18 @@ 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 / 100)) + : pricing.currentPrice; + + // 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) @@ -242,7 +359,12 @@ function RouteComponent() { >
- Limited Time Offer - {PRICING_CONFIG.DISCOUNT_PERCENTAGE}% OFF + {pricing.promoLabel}{discountPercentage > 0 ? ` - ${discountPercentage}% OFF` : ""} + {hasAffiliateDiscount && ( + + + {affiliateInfo.discountRate}% extra + + )}
@@ -304,21 +426,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..866e1c74 100644 --- a/src/use-cases/affiliates.ts +++ b/src/use-cases/affiliates.ts @@ -13,16 +13,37 @@ import { getAffiliatePayouts, getMonthlyAffiliateEarnings, getAllAffiliatesWithStats, + getAffiliateById, + getEligibleAffiliatesForAutoPayout, + createAffiliatePayoutWithStripeTransfer, + getPayoutByStripeTransferId, + updateAffiliateStripeAccount, + getAffiliateByStripeAccountId, + clearAffiliatePayoutError, + disconnectAffiliateStripeAccount, + getAffiliateWithUserEmail, + incrementPayoutRetryCount, + resetPayoutRetryCount, + createPendingPayout, + completePendingPayout, + failPendingPayout, } from "~/data-access/affiliates"; +import { getAffiliateCommissionRate } 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"; 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 +54,39 @@ 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 payment link if using link method + if (paymentMethod === "link") { + 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 it's a URL + try { + new URL(paymentLink); + } catch { + throw new ApplicationError( + "Payment link must be a valid URL", + "INVALID_PAYMENT_LINK" + ); + } } // 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,6 +96,118 @@ 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") { + if (!paymentLink || paymentLink.length < 10) { + throw new ApplicationError( + "Please provide a valid payment link", + "INVALID_PAYMENT_LINK" + ); + } + + try { + new URL(paymentLink); + } catch { + throw new ApplicationError( + "Payment link must be a valid URL", + "INVALID_PAYMENT_LINK" + ); + } + } + + // 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; +}) { + return updateAffiliateProfile(affiliateId, { isActive }); +} + +export async function adminUpdateAffiliateCommissionRateUseCase({ + affiliateId, + commissionRate, +}: { + affiliateId: number; + commissionRate: number; +}) { + if (commissionRate < 0 || commissionRate > 100) { + throw new ApplicationError( + "Commission rate must be between 0 and 100", + "INVALID_COMMISSION_RATE" + ); + } + + const { updateAffiliateCommissionRate } = await import("~/data-access/affiliates"); + 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, @@ -80,31 +219,62 @@ export async function processAffiliateReferralUseCase({ stripeSessionId: string; amount: number; }) { + // 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); 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); if (existingReferral) { - console.warn(`Duplicate Stripe session: ${stripeSessionId} already processed`); + logger.warn("Duplicate Stripe session already processed", { + fn: "processAffiliateReferralUseCase", + stripeSessionId, + }); return null; } - // Calculate commission + // Calculate commission (amount and rate are guaranteed to be within safe bounds due to validation above) const commission = Math.floor((amount * affiliate.commissionRate) / 100); // Create referral record @@ -160,51 +330,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 minimum balance + if (affiliate.unpaidBalance < AFFILIATE_CONFIG.MINIMUM_PAYOUT) { + return { + success: false, + error: `Balance ($${affiliate.unpaidBalance / 100}) below minimum payout ($${AFFILIATE_CONFIG.MINIMUM_PAYOUT / 100})`, + }; + } + + 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 { + // Create idempotency key to prevent duplicate payouts within same time window (1-minute buckets) + // Include the pending payout ID for additional uniqueness + const idempotencyKey = `payout-${affiliate.id}-${pendingPayout.id}-${payoutAmount}-${Math.floor(Date.now() / 60000)}`; + + 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; + }>; +}> { + const eligibleAffiliates = await getEligibleAffiliatesForAutoPayout( + AFFILIATE_CONFIG.MINIMUM_PAYOUT + ); + + 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" }; + } + + if (!affiliate.stripeConnectAccountId) { + return { success: false, error: "No Stripe Connect account linked" }; } - // Update payment link in database - return updateAffiliateProfile(affiliate.id, { paymentLink }); + return syncStripeAccountStatusUseCase(affiliate.stripeConnectAccountId); } export async function getAffiliateAnalyticsUseCase(userId: number) { @@ -236,42 +703,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..1b846901 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,15 @@ +/** + * 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( + "" + ); +} diff --git a/src/utils/email.ts b/src/utils/email.ts index 8f48d7e2..e1304d7a 100644 --- a/src/utils/email.ts +++ b/src/utils/email.ts @@ -5,6 +5,8 @@ import { env } from "~/utils/env"; 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({ @@ -258,3 +260,56 @@ 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)); + await sendEmail({ + to, + subject: `Your affiliate payout of ${props.payoutAmount} has been sent!`, + html, + }); + 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)); + await sendEmail({ + to, + subject: "Action required: Your affiliate payout could not be processed", + html, + }); + 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/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..c7912f8f --- /dev/null +++ b/src/utils/stripe-status.ts @@ -0,0 +1,40 @@ +import type Stripe from "stripe"; + +export const StripeAccountStatus = { + NOT_STARTED: "not_started", + ONBOARDING: "onboarding", + PENDING: "pending", + 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 string + * + * Status flow: + * - not_started: No account created yet (default in database) + * - onboarding: Account created but details not submitted + * - pending: Details submitted but not fully activated + * - restricted: Account has restrictions or disabled_reason + * - active: Fully activated with charges and payouts enabled + */ +export function determineStripeAccountStatus(account: Stripe.Account): string { + 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; + } + + return StripeAccountStatus.PENDING; +} From e9b2f75772fdb9bc4ec85d867dc8eb11d52952f5 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Fri, 26 Dec 2025 21:53:24 +0000 Subject: [PATCH 04/28] feat(affiliates): Add real activity timeline with avatars and pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show real referral/conversion history in Activity timeline - Show real payout history with status colors - Add avatar images to affiliate table and details sheet - Add pagination support for referrals and payouts (limit/offset) - Use NumberInputWithControls for commission rate editing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/data-access/affiliates.ts | 59 ++++++- src/fn/affiliates.ts | 62 ++++++- .../affiliate-details-sheet.tsx | 161 ++++++++---------- .../affiliates-columns.tsx | 15 +- 4 files changed, 200 insertions(+), 97 deletions(-) diff --git a/src/data-access/affiliates.ts b/src/data-access/affiliates.ts index 22afc483..dfa42a3f 100644 --- a/src/data-access/affiliates.ts +++ b/src/data-access/affiliates.ts @@ -76,7 +76,12 @@ export async function createAffiliateReferral(data: AffiliateReferralCreate) { return referral; } -export async function getAffiliateReferrals(affiliateId: number) { +export async function getAffiliateReferrals( + affiliateId: number, + options?: { limit?: number; offset?: number } +) { + const { limit = 10, offset = 0 } = options || {}; + const referrals = await database .select({ id: affiliateReferrals.id, @@ -93,8 +98,15 @@ export async function getAffiliateReferrals(affiliateId: number) { .leftJoin(users, eq(affiliateReferrals.purchaserId, users.id)) .leftJoin(profiles, eq(users.id, profiles.userId)) .where(eq(affiliateReferrals.affiliateId, affiliateId)) - .orderBy(desc(affiliateReferrals.createdAt)); - return referrals; + .orderBy(desc(affiliateReferrals.createdAt)) + .limit(limit + 1) // Fetch one extra to check if there's more + .offset(offset); + + const hasMore = referrals.length > limit; + return { + items: hasMore ? referrals.slice(0, limit) : referrals, + hasMore, + }; } export async function getAffiliateStats(affiliateId: number) { @@ -201,7 +213,12 @@ export async function createAffiliatePayout( }); } -export async function getAffiliatePayouts(affiliateId: number) { +export async function getAffiliatePayouts( + affiliateId: number, + options?: { limit?: number; offset?: number } +) { + const { limit = 10, offset = 0 } = options || {}; + const payouts = await database .select({ id: affiliatePayouts.id, @@ -211,6 +228,7 @@ export async function getAffiliatePayouts(affiliateId: number) { stripeTransferId: affiliatePayouts.stripeTransferId, notes: affiliatePayouts.notes, paidAt: affiliatePayouts.paidAt, + status: affiliatePayouts.status, // Don't expose admin email - only show display name for privacy paidByName: profiles.displayName, }) @@ -218,8 +236,15 @@ export async function getAffiliatePayouts(affiliateId: number) { .leftJoin(users, eq(affiliatePayouts.paidBy, users.id)) .leftJoin(profiles, eq(users.id, profiles.userId)) .where(eq(affiliatePayouts.affiliateId, affiliateId)) - .orderBy(desc(affiliatePayouts.paidAt)); - return payouts; + .orderBy(desc(affiliatePayouts.paidAt)) + .limit(limit + 1) + .offset(offset); + + const hasMore = payouts.length > limit; + return { + items: hasMore ? payouts.slice(0, limit) : payouts, + hasMore, + }; } export async function getAllAffiliatesWithStats() { @@ -231,6 +256,7 @@ export async function getAllAffiliatesWithStats() { userName: profiles.displayName, userRealName: profiles.realName, useDisplayName: profiles.useDisplayName, + userImage: profiles.image, affiliateCode: affiliates.affiliateCode, paymentLink: affiliates.paymentLink, paymentMethod: affiliates.paymentMethod, @@ -775,3 +801,24 @@ export async function updateAffiliateCommissionRate( .returning(); return updated; } + +/** + * Update an affiliate's discount rate (the portion of commission given to customers). + * Used by affiliates to control how much of their commission goes to customer discount vs their earnings. + * @param affiliateId - The affiliate's ID + * @param discountRate - The discount percentage (must be <= commissionRate) + */ +export async function updateAffiliateDiscountRate( + affiliateId: number, + discountRate: number +) { + const [updated] = await database + .update(affiliates) + .set({ + discountRate, + updatedAt: new Date(), + }) + .where(eq(affiliates.id, affiliateId)) + .returning(); + return updated; +} diff --git a/src/fn/affiliates.ts b/src/fn/affiliates.ts index a92ad055..71f0a7bb 100644 --- a/src/fn/affiliates.ts +++ b/src/fn/affiliates.ts @@ -19,7 +19,7 @@ import { refreshStripeAccountStatusForUserUseCase, disconnectStripeAccountUseCase, } from "~/use-cases/affiliates"; -import { getAffiliateByUserId } from "~/data-access/affiliates"; +import { getAffiliateByUserId, updateAffiliateDiscountRate, getAffiliatePayouts, getAffiliateReferrals } from "~/data-access/affiliates"; const affiliatesFeatureMiddleware = createFeatureFlagMiddleware("AFFILIATES_FEATURE"); @@ -109,6 +109,30 @@ export const updateAffiliatePaymentLinkFn = createServerFn() return updated; }); +const updateDiscountRateSchema = z.object({ + discountRate: z.number().min(0).max(100), +}); + +/** + * Update affiliate's discount rate (how much of their commission goes to customer discount). + * Affiliates can call this to adjust their commission split. + */ +export const updateAffiliateDiscountRateFn = createServerFn() + .middleware([authenticatedMiddleware, affiliatesFeatureMiddleware]) + .inputValidator(updateDiscountRateSchema) + .handler(async ({ data, context }) => { + const affiliate = await getAffiliateByUserId(context.userId); + if (!affiliate) { + throw new Error("Affiliate account not found"); + } + // Ensure discount rate doesn't exceed commission rate + if (data.discountRate > affiliate.commissionRate) { + throw new Error(`Discount rate cannot exceed your commission rate of ${affiliate.commissionRate}%`); + } + const updated = await updateAffiliateDiscountRate(affiliate.id, data.discountRate); + return updated; + }); + export const adminGetAllAffiliatesFn = createServerFn() .middleware([adminMiddleware]) .handler(async () => { @@ -229,3 +253,39 @@ export const disconnectStripeAccountFn = createServerFn({ method: "POST" }) affiliate, }; }); + +// Admin function to get affiliate payout history +const getAffiliatePayoutsSchema = z.object({ + affiliateId: z.number(), + limit: z.number().optional().default(10), + offset: z.number().optional().default(0), +}); + +export const adminGetAffiliatePayoutsFn = createServerFn() + .middleware([adminMiddleware]) + .inputValidator(getAffiliatePayoutsSchema) + .handler(async ({ data }) => { + const result = await getAffiliatePayouts(data.affiliateId, { + limit: data.limit, + offset: data.offset, + }); + return result; + }); + +// Admin function to get affiliate referral/conversion history +const getAffiliateReferralsSchema = z.object({ + affiliateId: z.number(), + limit: z.number().optional().default(10), + offset: z.number().optional().default(0), +}); + +export const adminGetAffiliateReferralsFn = createServerFn() + .middleware([adminMiddleware]) + .inputValidator(getAffiliateReferralsSchema) + .handler(async ({ data }) => { + const result = await getAffiliateReferrals(data.affiliateId, { + limit: data.limit, + offset: data.offset, + }); + return result; + }); diff --git a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx index e82afb34..8c58c44e 100644 --- a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx +++ b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sheet, SheetContent, @@ -18,26 +18,19 @@ import { DollarSign, CheckCircle, Percent, - Loader2, Copy, Activity, ArrowUpRight, User, - Minus, - Plus, - Save, } from "lucide-react"; -import { - ButtonGroup, - InputGroup, - InputGroupInput, - InputGroupAddon, -} from "~/components/ui/button-group"; +import { NumberInputWithControls } from "~/components/blocks/number-input-with-controls"; import { cn } from "~/lib/utils"; import type { AffiliateRow } from "./affiliates-columns"; import { adminToggleAffiliateStatusFn, adminUpdateAffiliateCommissionRateFn, + adminGetAffiliatePayoutsFn, + adminGetAffiliateReferralsFn, } from "~/fn/affiliates"; interface AffiliateDetailsSheetProps { @@ -82,6 +75,20 @@ export function AffiliateDetailsSheet({ const [editingRate, setEditingRate] = useState(false); const [newCommissionRate, setNewCommissionRate] = useState(""); + // Fetch payout history when sheet is open + const { data: payouts } = useQuery({ + queryKey: ["affiliatePayouts", affiliate?.id], + queryFn: () => adminGetAffiliatePayoutsFn({ data: { affiliateId: affiliate!.id } }), + enabled: open && !!affiliate?.id, + }); + + // Fetch referral/conversion history when sheet is open + const { data: referrals } = useQuery({ + queryKey: ["affiliateReferrals", affiliate?.id], + queryFn: () => adminGetAffiliateReferralsFn({ data: { affiliateId: affiliate!.id } }), + enabled: open && !!affiliate?.id, + }); + const toggleStatusMutation = useMutation({ mutationFn: adminToggleAffiliateStatusFn, onSuccess: () => { @@ -154,9 +161,17 @@ export function AffiliateDetailsSheet({
{/* Avatar */}
-
- -
+ {affiliate.userImage ? ( + {displayName} + ) : ( +
+ +
+ )}
Commission Rate
- - - { - const val = e.target.value; - if (val === "" || /^\d+$/.test(val)) { - let numVal = val === "" ? 0 : parseInt(val, 10); - if (numVal > 100) numVal = 100; - if (numVal < 0) numVal = 0; - setNewCommissionRate(numVal.toString()); - if (!editingRate) setEditingRate(true); - } - }} - className="text-center text-sm h-8" - /> - % - - - - - + { + 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 */} @@ -387,25 +355,44 @@ export function AffiliateDetailsSheet({ {/* Timeline items */}
- {affiliate.lastReferralDate && ( -
+ {/* Real referral/conversion history */} + {referrals?.map((referral) => ( +
-

New referral converted

-

{formatDate(affiliate.lastReferralDate)}

+

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

+

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

- )} + ))} - {affiliate.paidAmount > 0 && ( -
-
+ {/* Real payout history */} + {payouts?.map((payout) => ( +
+
-

Payout processed

-

{formatCurrency(affiliate.paidAmount)} total paid

+

+ {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 && (
diff --git a/src/routes/admin/-affiliates-components/affiliates-columns.tsx b/src/routes/admin/-affiliates-components/affiliates-columns.tsx index 2f20b845..99dfdb0e 100644 --- a/src/routes/admin/-affiliates-components/affiliates-columns.tsx +++ b/src/routes/admin/-affiliates-components/affiliates-columns.tsx @@ -18,6 +18,7 @@ export type AffiliateRow = { userId: number; userEmail: string | null; userName: string | null; + userImage: string | null; affiliateCode: string; paymentLink: string | null; paymentMethod: string; @@ -73,9 +74,17 @@ export function getAffiliateColumns(
{/* Avatar with status indicator */}
-
- {initial} -
+ {affiliate.userImage ? ( + {displayName} + ) : ( +
+ {initial} +
+ )}
Date: Fri, 26 Dec 2025 21:53:55 +0000 Subject: [PATCH 05/28] feat(affiliates): Stripe Connect OAuth and affiliate dashboard improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Stripe Connect OAuth routes for affiliate onboarding - Update affiliate dashboard with improved layout - Add affiliate use-cases updates - Update schema and route tree 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/db/schema.ts | 9 +- src/routeTree.gen.ts | 131 +++++++++++++ src/routes/admin/affiliates.tsx | 161 +++------------- src/routes/affiliate-dashboard.tsx | 177 +++++++++++++++--- src/routes/api/connect/stripe/index.ts | 33 ++-- .../connect/stripe/oauth/callback/index.ts | 125 +++++++++++++ src/routes/api/connect/stripe/oauth/index.ts | 90 +++++++++ src/routes/api/stripe/webhook.ts | 92 +++------ src/use-cases/affiliates.ts | 68 +++++-- 9 files changed, 624 insertions(+), 262 deletions(-) create mode 100644 src/routes/api/connect/stripe/oauth/callback/index.ts create mode 100644 src/routes/api/connect/stripe/oauth/index.ts diff --git a/src/db/schema.ts b/src/db/schema.ts index 6cf03d38..650db79a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -123,7 +123,7 @@ export const segments = tableCreator( icon: text("icon"), // Lucide icon name, e.g., "PlayCircle", "Code", "FileText" isPremium: boolean("isPremium").notNull().default(false), isComingSoon: boolean("isComingSoon").notNull().default(false), - moduleId: serial("moduleId") + moduleId: integer("moduleId") .notNull() .references(() => modules.id, { onDelete: "cascade" }), videoKey: text("videoKey"), @@ -259,6 +259,9 @@ export const affiliates = tableCreator( paymentMethod: text("paymentMethod").notNull().default("link"), // 'link' or 'stripe' paymentLink: text("paymentLink"), commissionRate: integer("commissionRate").notNull().default(30), + // Discount rate: percentage of commission given to customer as discount + // E.g., if commissionRate=30 and discountRate=15, customer gets 15% off, affiliate earns 15% + discountRate: integer("discountRate").notNull().default(0), totalEarnings: integer("totalEarnings").notNull().default(0), paidAmount: integer("paidAmount").notNull().default(0), unpaidBalance: integer("unpaidBalance").notNull().default(0), @@ -304,6 +307,10 @@ export const affiliateReferrals = tableCreator( stripeSessionId: text("stripeSessionId").notNull(), amount: integer("amount").notNull(), commission: integer("commission").notNull(), + // Frozen rates at checkout time for audit trail + commissionRate: integer("commissionRate"), // The effective rate used (originalRate - discountRate) + discountRate: integer("discountRate"), // Customer discount given by affiliate + originalCommissionRate: integer("originalCommissionRate"), // Platform's base commission rate isPaid: boolean("isPaid").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), }, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0869552e..7af05a50 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -51,6 +51,7 @@ import { Route as AdminVideoProcessingRouteImport } from './routes/admin/video-p import { Route as AdminUtmAnalyticsRouteImport } from './routes/admin/utm-analytics' import { Route as AdminUsersRouteImport } from './routes/admin/users' import { Route as AdminSettingsRouteImport } from './routes/admin/settings' +import { Route as AdminPricingRouteImport } from './routes/admin/pricing' import { Route as AdminCommentsRouteImport } from './routes/admin/comments' import { Route as AdminAnalyticsRouteImport } from './routes/admin/analytics' import { Route as AdminAffiliatesRouteImport } from './routes/admin/affiliates' @@ -77,12 +78,17 @@ import { Route as AdminConversionsEventsRouteImport } from './routes/admin/conve import { Route as AdminBlogNewRouteImport } from './routes/admin/blog/new' import { Route as LearnSlugLayoutIndexRouteImport } from './routes/learn/$slug/_layout.index' import { Route as ApiLoginGoogleIndexRouteImport } from './routes/api/login/google/index' +import { Route as ApiConnectStripeIndexRouteImport } from './routes/api/connect/stripe/index' import { Route as AdminLaunchKitsCreateIndexRouteImport } from './routes/admin/launch-kits/create/index' import { Route as ApiSegmentsSegmentIdVideoRouteImport } from './routes/api/segments/$segmentId/video' import { Route as AdminNewsIdEditRouteImport } from './routes/admin/news/$id/edit' import { Route as AdminLaunchKitsEditIdRouteImport } from './routes/admin/launch-kits/edit/$id' import { Route as AdminBlogIdEditRouteImport } from './routes/admin/blog/$id/edit' import { Route as ApiLoginGoogleCallbackIndexRouteImport } from './routes/api/login/google/callback/index' +import { Route as ApiConnectStripeRefreshIndexRouteImport } from './routes/api/connect/stripe/refresh/index' +import { Route as ApiConnectStripeOauthIndexRouteImport } from './routes/api/connect/stripe/oauth/index' +import { Route as ApiConnectStripeCallbackIndexRouteImport } from './routes/api/connect/stripe/callback/index' +import { Route as ApiConnectStripeOauthCallbackIndexRouteImport } from './routes/api/connect/stripe/oauth/callback/index' const UnsubscribeRoute = UnsubscribeRouteImport.update({ id: '/unsubscribe', @@ -294,6 +300,11 @@ const AdminSettingsRoute = AdminSettingsRouteImport.update({ path: '/settings', getParentRoute: () => AdminRouteRoute, } as any) +const AdminPricingRoute = AdminPricingRouteImport.update({ + id: '/pricing', + path: '/pricing', + getParentRoute: () => AdminRouteRoute, +} as any) const AdminCommentsRoute = AdminCommentsRouteImport.update({ id: '/comments', path: '/comments', @@ -425,6 +436,11 @@ const ApiLoginGoogleIndexRoute = ApiLoginGoogleIndexRouteImport.update({ path: '/api/login/google/', getParentRoute: () => rootRouteImport, } as any) +const ApiConnectStripeIndexRoute = ApiConnectStripeIndexRouteImport.update({ + id: '/api/connect/stripe/', + path: '/api/connect/stripe/', + getParentRoute: () => rootRouteImport, +} as any) const AdminLaunchKitsCreateIndexRoute = AdminLaunchKitsCreateIndexRouteImport.update({ id: '/launch-kits/create/', @@ -458,6 +474,30 @@ const ApiLoginGoogleCallbackIndexRoute = path: '/api/login/google/callback/', getParentRoute: () => rootRouteImport, } as any) +const ApiConnectStripeRefreshIndexRoute = + ApiConnectStripeRefreshIndexRouteImport.update({ + id: '/api/connect/stripe/refresh/', + path: '/api/connect/stripe/refresh/', + getParentRoute: () => rootRouteImport, + } as any) +const ApiConnectStripeOauthIndexRoute = + ApiConnectStripeOauthIndexRouteImport.update({ + id: '/api/connect/stripe/oauth/', + path: '/api/connect/stripe/oauth/', + getParentRoute: () => rootRouteImport, + } as any) +const ApiConnectStripeCallbackIndexRoute = + ApiConnectStripeCallbackIndexRouteImport.update({ + id: '/api/connect/stripe/callback/', + path: '/api/connect/stripe/callback/', + getParentRoute: () => rootRouteImport, + } as any) +const ApiConnectStripeOauthCallbackIndexRoute = + ApiConnectStripeOauthCallbackIndexRouteImport.update({ + id: '/api/connect/stripe/oauth/callback/', + path: '/api/connect/stripe/oauth/callback/', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -487,6 +527,7 @@ export interface FileRoutesByFullPath { '/admin/affiliates': typeof AdminAffiliatesRoute '/admin/analytics': typeof AdminAnalyticsRoute '/admin/comments': typeof AdminCommentsRoute + '/admin/pricing': typeof AdminPricingRoute '/admin/settings': typeof AdminSettingsRoute '/admin/users': typeof AdminUsersRoute '/admin/utm-analytics': typeof AdminUtmAnalyticsRoute @@ -531,9 +572,14 @@ export interface FileRoutesByFullPath { '/admin/news/$id/edit': typeof AdminNewsIdEditRoute '/api/segments/$segmentId/video': typeof ApiSegmentsSegmentIdVideoRoute '/admin/launch-kits/create': typeof AdminLaunchKitsCreateIndexRoute + '/api/connect/stripe': typeof ApiConnectStripeIndexRoute '/api/login/google': typeof ApiLoginGoogleIndexRoute '/learn/$slug/': typeof LearnSlugLayoutIndexRoute + '/api/connect/stripe/callback': typeof ApiConnectStripeCallbackIndexRoute + '/api/connect/stripe/oauth': typeof ApiConnectStripeOauthIndexRoute + '/api/connect/stripe/refresh': typeof ApiConnectStripeRefreshIndexRoute '/api/login/google/callback': typeof ApiLoginGoogleCallbackIndexRoute + '/api/connect/stripe/oauth/callback': typeof ApiConnectStripeOauthCallbackIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -561,6 +607,7 @@ export interface FileRoutesByTo { '/admin/affiliates': typeof AdminAffiliatesRoute '/admin/analytics': typeof AdminAnalyticsRoute '/admin/comments': typeof AdminCommentsRoute + '/admin/pricing': typeof AdminPricingRoute '/admin/settings': typeof AdminSettingsRoute '/admin/users': typeof AdminUsersRoute '/admin/utm-analytics': typeof AdminUtmAnalyticsRoute @@ -604,9 +651,14 @@ export interface FileRoutesByTo { '/admin/news/$id/edit': typeof AdminNewsIdEditRoute '/api/segments/$segmentId/video': typeof ApiSegmentsSegmentIdVideoRoute '/admin/launch-kits/create': typeof AdminLaunchKitsCreateIndexRoute + '/api/connect/stripe': typeof ApiConnectStripeIndexRoute '/api/login/google': typeof ApiLoginGoogleIndexRoute '/learn/$slug': typeof LearnSlugLayoutIndexRoute + '/api/connect/stripe/callback': typeof ApiConnectStripeCallbackIndexRoute + '/api/connect/stripe/oauth': typeof ApiConnectStripeOauthIndexRoute + '/api/connect/stripe/refresh': typeof ApiConnectStripeRefreshIndexRoute '/api/login/google/callback': typeof ApiLoginGoogleCallbackIndexRoute + '/api/connect/stripe/oauth/callback': typeof ApiConnectStripeOauthCallbackIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -637,6 +689,7 @@ export interface FileRoutesById { '/admin/affiliates': typeof AdminAffiliatesRoute '/admin/analytics': typeof AdminAnalyticsRoute '/admin/comments': typeof AdminCommentsRoute + '/admin/pricing': typeof AdminPricingRoute '/admin/settings': typeof AdminSettingsRoute '/admin/users': typeof AdminUsersRoute '/admin/utm-analytics': typeof AdminUtmAnalyticsRoute @@ -681,9 +734,14 @@ export interface FileRoutesById { '/admin/news/$id/edit': typeof AdminNewsIdEditRoute '/api/segments/$segmentId/video': typeof ApiSegmentsSegmentIdVideoRoute '/admin/launch-kits/create/': typeof AdminLaunchKitsCreateIndexRoute + '/api/connect/stripe/': typeof ApiConnectStripeIndexRoute '/api/login/google/': typeof ApiLoginGoogleIndexRoute '/learn/$slug/_layout/': typeof LearnSlugLayoutIndexRoute + '/api/connect/stripe/callback/': typeof ApiConnectStripeCallbackIndexRoute + '/api/connect/stripe/oauth/': typeof ApiConnectStripeOauthIndexRoute + '/api/connect/stripe/refresh/': typeof ApiConnectStripeRefreshIndexRoute '/api/login/google/callback/': typeof ApiLoginGoogleCallbackIndexRoute + '/api/connect/stripe/oauth/callback/': typeof ApiConnectStripeOauthCallbackIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -715,6 +773,7 @@ export interface FileRouteTypes { | '/admin/affiliates' | '/admin/analytics' | '/admin/comments' + | '/admin/pricing' | '/admin/settings' | '/admin/users' | '/admin/utm-analytics' @@ -759,9 +818,14 @@ export interface FileRouteTypes { | '/admin/news/$id/edit' | '/api/segments/$segmentId/video' | '/admin/launch-kits/create' + | '/api/connect/stripe' | '/api/login/google' | '/learn/$slug/' + | '/api/connect/stripe/callback' + | '/api/connect/stripe/oauth' + | '/api/connect/stripe/refresh' | '/api/login/google/callback' + | '/api/connect/stripe/oauth/callback' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -789,6 +853,7 @@ export interface FileRouteTypes { | '/admin/affiliates' | '/admin/analytics' | '/admin/comments' + | '/admin/pricing' | '/admin/settings' | '/admin/users' | '/admin/utm-analytics' @@ -832,9 +897,14 @@ export interface FileRouteTypes { | '/admin/news/$id/edit' | '/api/segments/$segmentId/video' | '/admin/launch-kits/create' + | '/api/connect/stripe' | '/api/login/google' | '/learn/$slug' + | '/api/connect/stripe/callback' + | '/api/connect/stripe/oauth' + | '/api/connect/stripe/refresh' | '/api/login/google/callback' + | '/api/connect/stripe/oauth/callback' id: | '__root__' | '/' @@ -864,6 +934,7 @@ export interface FileRouteTypes { | '/admin/affiliates' | '/admin/analytics' | '/admin/comments' + | '/admin/pricing' | '/admin/settings' | '/admin/users' | '/admin/utm-analytics' @@ -908,9 +979,14 @@ export interface FileRouteTypes { | '/admin/news/$id/edit' | '/api/segments/$segmentId/video' | '/admin/launch-kits/create/' + | '/api/connect/stripe/' | '/api/login/google/' | '/learn/$slug/_layout/' + | '/api/connect/stripe/callback/' + | '/api/connect/stripe/oauth/' + | '/api/connect/stripe/refresh/' | '/api/login/google/callback/' + | '/api/connect/stripe/oauth/callback/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -956,8 +1032,13 @@ export interface RootRouteChildren { LearnSlugLayoutRoute: typeof LearnSlugLayoutRouteWithChildren LearnSlugEditRoute: typeof LearnSlugEditRoute ApiSegmentsSegmentIdVideoRoute: typeof ApiSegmentsSegmentIdVideoRoute + ApiConnectStripeIndexRoute: typeof ApiConnectStripeIndexRoute ApiLoginGoogleIndexRoute: typeof ApiLoginGoogleIndexRoute + ApiConnectStripeCallbackIndexRoute: typeof ApiConnectStripeCallbackIndexRoute + ApiConnectStripeOauthIndexRoute: typeof ApiConnectStripeOauthIndexRoute + ApiConnectStripeRefreshIndexRoute: typeof ApiConnectStripeRefreshIndexRoute ApiLoginGoogleCallbackIndexRoute: typeof ApiLoginGoogleCallbackIndexRoute + ApiConnectStripeOauthCallbackIndexRoute: typeof ApiConnectStripeOauthCallbackIndexRoute } declare module '@tanstack/react-router' { @@ -1256,6 +1337,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminSettingsRouteImport parentRoute: typeof AdminRouteRoute } + '/admin/pricing': { + id: '/admin/pricing' + path: '/pricing' + fullPath: '/admin/pricing' + preLoaderRoute: typeof AdminPricingRouteImport + parentRoute: typeof AdminRouteRoute + } '/admin/comments': { id: '/admin/comments' path: '/comments' @@ -1438,6 +1526,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiLoginGoogleIndexRouteImport parentRoute: typeof rootRouteImport } + '/api/connect/stripe/': { + id: '/api/connect/stripe/' + path: '/api/connect/stripe' + fullPath: '/api/connect/stripe' + preLoaderRoute: typeof ApiConnectStripeIndexRouteImport + parentRoute: typeof rootRouteImport + } '/admin/launch-kits/create/': { id: '/admin/launch-kits/create/' path: '/launch-kits/create' @@ -1480,6 +1575,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiLoginGoogleCallbackIndexRouteImport parentRoute: typeof rootRouteImport } + '/api/connect/stripe/refresh/': { + id: '/api/connect/stripe/refresh/' + path: '/api/connect/stripe/refresh' + fullPath: '/api/connect/stripe/refresh' + preLoaderRoute: typeof ApiConnectStripeRefreshIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/connect/stripe/oauth/': { + id: '/api/connect/stripe/oauth/' + path: '/api/connect/stripe/oauth' + fullPath: '/api/connect/stripe/oauth' + preLoaderRoute: typeof ApiConnectStripeOauthIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/connect/stripe/callback/': { + id: '/api/connect/stripe/callback/' + path: '/api/connect/stripe/callback' + fullPath: '/api/connect/stripe/callback' + preLoaderRoute: typeof ApiConnectStripeCallbackIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/connect/stripe/oauth/callback/': { + id: '/api/connect/stripe/oauth/callback/' + path: '/api/connect/stripe/oauth/callback' + fullPath: '/api/connect/stripe/oauth/callback' + preLoaderRoute: typeof ApiConnectStripeOauthCallbackIndexRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -1529,6 +1652,7 @@ interface AdminRouteRouteChildren { AdminAffiliatesRoute: typeof AdminAffiliatesRoute AdminAnalyticsRoute: typeof AdminAnalyticsRoute AdminCommentsRoute: typeof AdminCommentsRoute + AdminPricingRoute: typeof AdminPricingRoute AdminSettingsRoute: typeof AdminSettingsRoute AdminUsersRoute: typeof AdminUsersRoute AdminUtmAnalyticsRoute: typeof AdminUtmAnalyticsRoute @@ -1551,6 +1675,7 @@ const AdminRouteRouteChildren: AdminRouteRouteChildren = { AdminAffiliatesRoute: AdminAffiliatesRoute, AdminAnalyticsRoute: AdminAnalyticsRoute, AdminCommentsRoute: AdminCommentsRoute, + AdminPricingRoute: AdminPricingRoute, AdminSettingsRoute: AdminSettingsRoute, AdminUsersRoute: AdminUsersRoute, AdminUtmAnalyticsRoute: AdminUtmAnalyticsRoute, @@ -1626,8 +1751,14 @@ const rootRouteChildren: RootRouteChildren = { LearnSlugLayoutRoute: LearnSlugLayoutRouteWithChildren, LearnSlugEditRoute: LearnSlugEditRoute, ApiSegmentsSegmentIdVideoRoute: ApiSegmentsSegmentIdVideoRoute, + ApiConnectStripeIndexRoute: ApiConnectStripeIndexRoute, ApiLoginGoogleIndexRoute: ApiLoginGoogleIndexRoute, + ApiConnectStripeCallbackIndexRoute: ApiConnectStripeCallbackIndexRoute, + ApiConnectStripeOauthIndexRoute: ApiConnectStripeOauthIndexRoute, + ApiConnectStripeRefreshIndexRoute: ApiConnectStripeRefreshIndexRoute, ApiLoginGoogleCallbackIndexRoute: ApiLoginGoogleCallbackIndexRoute, + ApiConnectStripeOauthCallbackIndexRoute: + ApiConnectStripeOauthCallbackIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/admin/affiliates.tsx b/src/routes/admin/affiliates.tsx index 0b6d35a6..ac2a4526 100644 --- a/src/routes/admin/affiliates.tsx +++ b/src/routes/admin/affiliates.tsx @@ -48,17 +48,8 @@ import { TrendingUp, Search, Clock, - Minus, - Plus, - Save, - Loader2, } from "lucide-react"; -import { - ButtonGroup, - InputGroup, - InputGroupInput, - InputGroupAddon, -} from "~/components/ui/button-group"; +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"; @@ -458,131 +449,35 @@ function AdminAffiliates() { {/* Right side controls */}
{/* Minimum Payout */} - - - Min - $ - { - const val = e.target.value; - if (val === "" || /^\d+$/.test(val)) { - let numVal = val === "" ? 0 : parseInt(val, 10); - if (numVal < 0) numVal = 0; - minimumPayoutState.handleAmountChange(numVal.toString()); - } - }} - disabled={ - minimumPayoutState.isLoading || minimumPayoutState.isPending - } - className="text-center text-sm h-10" - /> - - - - - + {/* Default Multiplier */} - - - Rate - { - const val = e.target.value; - if (val === "" || /^\d+$/.test(val)) { - let numVal = val === "" ? 0 : parseInt(val, 10); - if (numVal > 100) numVal = 100; - if (numVal < 0) numVal = 0; - commissionRateState.handleRateChange(numVal.toString()); - } - }} - disabled={ - commissionRateState.isLoading || commissionRateState.isPending - } - className="text-center text-sm h-10" - /> - % - - - - - + {/* Batch Settle Button */}
- {dashboard.affiliate.commissionRate}% commission + {dashboard.affiliate.commissionRate - localDiscountRate}% commission
@@ -462,27 +486,39 @@ function AffiliateDashboard() { {/* Account Status Display */} {dashboard.affiliate.stripeAccountStatus === "not_started" && (
-
+

Connect Your Stripe Account

- Set up Stripe Connect to receive automatic payouts when you reach $50 + Set up Stripe to receive automatic payouts on every sale

- - - Connect Stripe Account - +
)} @@ -624,6 +660,87 @@ function AffiliateDashboard() { )} + {/* Share the Benefit Card */} + + +
+ +
+
+ + + Share the Benefit + + + Offer your audience a discount and boost your conversions. The more you share, the more attractive your offer becomes. + +
+
+
+ +
+
+ + + Customer Discount + + + {localDiscountRate}% + +
+ { + setLocalDiscountRate(value[0]); + }} + onValueCommit={(value) => { + updateDiscountRateMutation.mutate({ data: { discountRate: value[0] } }); + }} + disabled={updateDiscountRateMutation.isPending} + className="w-full" + /> +
+ + + Your Earnings + + + {dashboard.affiliate.commissionRate - localDiscountRate}% + +
+
+ +
+
+ +

Customer saves

+

+ ${((199 * localDiscountRate) / 100).toFixed(0)} +

+

on $199 course

+
+
+ +

You earn

+

+ ${((199 * (dashboard.affiliate.commissionRate - localDiscountRate)) / 100).toFixed(0)} +

+

per sale

+
+
+ + {localDiscountRate > 0 && ( +

+ Customers using your link will see a {localDiscountRate}% discount applied at checkout +

+ )} +
+
+
+ {/* Stats Grid */} )}
- - - Paid - + {payout.status === "completed" ? ( + + + Paid + + ) : payout.status === "pending" ? ( + + + Pending + + ) : ( + + + Failed + + )}
))}
diff --git a/src/routes/api/connect/stripe/index.ts b/src/routes/api/connect/stripe/index.ts index 8ad85aef..d6c3385e 100644 --- a/src/routes/api/connect/stripe/index.ts +++ b/src/routes/api/connect/stripe/index.ts @@ -1,5 +1,4 @@ import { createFileRoute } from "@tanstack/react-router"; -import { setCookie } from "@tanstack/react-start/server"; import { stripe } from "~/lib/stripe"; import { assertAuthenticated } from "~/utils/session"; import { @@ -14,6 +13,11 @@ 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. * @@ -64,26 +68,6 @@ export const Route = createFileRoute("/api/connect/stripe/")({ // Generate CSRF state token const state = generateCsrfState(); - // Store state in HTTP-only cookie for CSRF protection - // 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", state, { - 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, - }); - try { let accountId = affiliate.stripeConnectAccountId; @@ -114,7 +98,12 @@ export const Route = createFileRoute("/api/connect/stripe/")({ type: "account_onboarding", }); - return Response.redirect(accountLink.url); + // 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", { 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..07f89993 --- /dev/null +++ b/src/routes/api/connect/stripe/oauth/callback/index.ts @@ -0,0 +1,125 @@ +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, StripeAccountStatus } from "~/utils/stripe-status"; + +const AFTER_CONNECT_URL = "/affiliate-dashboard"; + +/** + * 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; + + // Clear cookies + deleteCookie("stripe_oauth_state"); + deleteCookie("stripe_oauth_affiliate_id"); + deleteCookie("stripe_oauth_type"); + + // Handle OAuth errors (user denied, etc.) + if (error) { + console.error("Stripe OAuth error:", error, errorDescription); + return new Response(null, { + status: 302, + headers: { Location: `${AFTER_CONNECT_URL}?error=oauth_denied` }, + }); + } + + // Validate CSRF state token + if (!state || !storedState || state !== storedState) { + return new Response("Invalid state parameter", { status: 400 }); + } + + if (!code) { + return new Response("Missing authorization code", { status: 400 }); + } + + 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, + lastStripeSync: new Date(), + }); + + // Redirect to affiliate dashboard with success message + return new Response(null, { + status: 302, + headers: { Location: `${AFTER_CONNECT_URL}?connected=true` }, + }); + } 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..628dec5a --- /dev/null +++ b/src/routes/api/connect/stripe/oauth/index.ts @@ -0,0 +1,90 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { assertAuthenticated } from "~/utils/session"; +import { + getAffiliateByUserId, + recordConnectAttempt, +} 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 }); + } + + // Record this attempt for rate limiting + await recordConnectAttempt(affiliate.id); + + // 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/stripe/webhook.ts b/src/routes/api/stripe/webhook.ts index 8b6e32ca..9ea2e091 100644 --- a/src/routes/api/stripe/webhook.ts +++ b/src/routes/api/stripe/webhook.ts @@ -3,7 +3,6 @@ import { stripe } from "~/lib/stripe"; import { updateUserToPremiumUseCase } from "~/use-cases/users"; import { processAffiliateReferralUseCase, - processAutomaticPayoutsUseCase, syncStripeAccountStatusUseCase, } from "~/use-cases/affiliates"; import { @@ -11,19 +10,11 @@ import { getPayoutByStripeTransferId, getAffiliateByStripeAccountIdWithUserEmail, updateAffiliatePayoutError, - updateLastPayoutAttempt, } from "~/data-access/affiliates"; -import { env } from "~/utils/env"; import { trackAnalyticsEvent } from "~/data-access/analytics"; -import { AFFILIATE_CONFIG } from "~/config"; import { sendAffiliatePayoutFailedEmail } from "~/utils/email"; import { logger } from "~/utils/logger"; - -// System user ID for automatic payouts (configured via environment variable) -const SYSTEM_USER_ID = env.SYSTEM_USER_ID; - -// Cooldown period for automatic payouts to prevent duplicate processing from webhook replays (60 seconds) -const PAYOUT_COOLDOWN_MS = 60000; +import { env } from "~/utils/env"; // Map Stripe error codes/messages to user-friendly messages function getUserFriendlyPayoutError(stripeError: string | undefined): string { @@ -112,11 +103,31 @@ export const Route = createFileRoute("/api/stripe/webhook")({ // 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) { @@ -125,67 +136,8 @@ export const Route = createFileRoute("/api/stripe/webhook")({ affiliateCode, sessionId: session.id, commission: referral.commission / 100, + isAutoTransfer, }); - - // Trigger automatic payout if affiliate is eligible - // Check if balance >= minimum and Stripe Connect is enabled - try { - const affiliate = await getAffiliateByCode(affiliateCode); - if ( - affiliate && - affiliate.stripePayoutsEnabled && - affiliate.unpaidBalance >= AFFILIATE_CONFIG.MINIMUM_PAYOUT - ) { - // Check for recent payout attempt (cooldown protection against webhook replays) - let shouldSkipPayout = false; - if (affiliate.lastPayoutAttemptAt) { - const timeSinceLastAttempt = Date.now() - affiliate.lastPayoutAttemptAt.getTime(); - if (timeSinceLastAttempt < PAYOUT_COOLDOWN_MS) { - logger.info("Skipping payout: cooldown active", { - fn: "stripe-webhook", - affiliateId: affiliate.id, - timeSinceLastAttempt, - }); - shouldSkipPayout = true; - } - } - - if (!shouldSkipPayout) { - // Update timestamp before attempting payout - await updateLastPayoutAttempt(affiliate.id); - - logger.info("Triggering automatic payout", { - fn: "stripe-webhook", - affiliateId: affiliate.id, - balance: affiliate.unpaidBalance / 100, - }); - const payoutResult = await processAutomaticPayoutsUseCase({ - affiliateId: affiliate.id, - systemUserId: SYSTEM_USER_ID, - }); - if (payoutResult.success) { - logger.info("Automatic payout successful", { - fn: "stripe-webhook", - affiliateId: affiliate.id, - amount: (payoutResult.amount ?? 0) / 100, - transferId: payoutResult.transferId, - }); - } else { - logger.warn("Automatic payout skipped", { - fn: "stripe-webhook", - affiliateId: affiliate.id, - error: payoutResult.error, - }); - } - } - } - } catch (payoutError) { - logger.error("Failed to process automatic payout for affiliate after referral", { - fn: "stripe-webhook", - error: payoutError instanceof Error ? payoutError.message : String(payoutError), - }); - // Don't fail webhook for payout errors - } } else { logger.warn("Affiliate referral not processed", { fn: "stripe-webhook", diff --git a/src/use-cases/affiliates.ts b/src/use-cases/affiliates.ts index 866e1c74..55aa69d1 100644 --- a/src/use-cases/affiliates.ts +++ b/src/use-cases/affiliates.ts @@ -28,7 +28,7 @@ import { completePendingPayout, failPendingPayout, } from "~/data-access/affiliates"; -import { getAffiliateCommissionRate } from "~/data-access/app-settings"; +import { getAffiliateCommissionRate, getAffiliateMinimumPayout } from "~/data-access/app-settings"; import { ApplicationError } from "./errors"; import { AFFILIATE_CONFIG } from "~/config"; import { stripe } from "~/lib/stripe"; @@ -213,11 +213,23 @@ export async function processAffiliateReferralUseCase({ 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) { @@ -274,21 +286,46 @@ export async function processAffiliateReferralUseCase({ return null; } - // Calculate commission (amount and rate are guaranteed to be within safe bounds due to validation above) - 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 + // Create referral record with frozen rates for audit trail + // If isAutoTransfer (transfer_data used), mark as paid immediately - Stripe handles the transfer const referral = await createAffiliateReferral({ 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); + // 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 and paidAmount (balance stays same) + await updateAffiliateBalances(affiliate.id, commission, 0); + // Also increment paidAmount since Stripe will transfer it + const { affiliates } = await import("~/db/schema"); + const { eq, sql } = await import("drizzle-orm"); + const { database } = await import("~/db"); + await database.update(affiliates) + .set({ + paidAmount: sql`${affiliates.paidAmount} + ${commission}`, + updatedAt: new Date() + }) + .where(eq(affiliates.id, affiliate.id)); + } else { + // For manual payout, add to both totalEarnings and unpaidBalance + await updateAffiliateBalances(affiliate.id, commission, commission); + } return referral; }); @@ -309,10 +346,11 @@ export async function recordAffiliatePayoutUseCase({ notes?: string; paidBy: number; }) { - // Validate minimum payout - if (amount < AFFILIATE_CONFIG.MINIMUM_PAYOUT) { + // Validate minimum payout (configurable via admin settings) + const minimumPayout = await getAffiliateMinimumPayout(); + if (amount < minimumPayout) { throw new ApplicationError( - `Minimum payout amount is $${AFFILIATE_CONFIG.MINIMUM_PAYOUT / 100}`, + `Minimum payout amount is $${minimumPayout / 100}`, "MINIMUM_PAYOUT_NOT_MET" ); } @@ -366,11 +404,12 @@ export async function processAutomaticPayoutsUseCase({ return { success: false, error: "Stripe payouts not enabled for this affiliate" }; } - // Validate minimum balance - if (affiliate.unpaidBalance < AFFILIATE_CONFIG.MINIMUM_PAYOUT) { + // 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: `Balance ($${affiliate.unpaidBalance / 100}) below minimum payout ($${AFFILIATE_CONFIG.MINIMUM_PAYOUT / 100})`, + error: "No unpaid balance to process", }; } @@ -557,9 +596,8 @@ export async function processAllAutomaticPayoutsUseCase({ error?: string; }>; }> { - const eligibleAffiliates = await getEligibleAffiliatesForAutoPayout( - AFFILIATE_CONFIG.MINIMUM_PAYOUT - ); + // Stripe Connect affiliates have no minimum threshold - pay any positive balance + const eligibleAffiliates = await getEligibleAffiliatesForAutoPayout(1); const results: Array<{ affiliateId: number; From fb50198ab2c0174d94a003e2fa3b95d7ebf779c3 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Fri, 26 Dec 2025 21:54:18 +0000 Subject: [PATCH 06/28] chore: Update dependencies and config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update package.json and pnpm-lock.yaml - Update vite.config.ts - Update env.ts with new variables - Minor UI and client updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package.json | 7 ++++--- pnpm-lock.yaml | 3 +++ src/client.tsx | 5 ++++- src/components/ui/button.tsx | 3 +++ src/config.ts | 8 +++++++- src/routes/__root.tsx | 7 +++++-- src/utils/env.ts | 5 +++++ vite.config.ts | 7 ++++++- 8 files changed, 37 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index b71a72cb..ecc5e97d 100644 --- a/package.json +++ b/package.json @@ -73,11 +73,11 @@ "@stripe/stripe-js": "^7.6.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.83.0", - "@tanstack/react-query-devtools": "^5.83.0", + "@tanstack/react-query-devtools": "^5.83.0", "@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", + "@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", @@ -137,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 4756a31e..6ff2d4f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,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 diff --git a/src/client.tsx b/src/client.tsx index 8c241bc3..2adce2cb 100644 --- a/src/client.tsx +++ b/src/client.tsx @@ -1,10 +1,13 @@ import { StartClient } from "@tanstack/react-start/client"; import { StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; +import { NuqsAdapter } from "nuqs/adapters/react"; hydrateRoot( document, - + + + ); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e4c10d8d..356d2ae2 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -19,6 +19,9 @@ const buttonVariants = cva( ghost: "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 has-[>svg]:px-3", diff --git a/src/config.ts b/src/config.ts index fdd85213..8236b5b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,7 +20,9 @@ export const PRICING_CONFIG = { // Affiliate program configuration export const AFFILIATE_CONFIG = { COMMISSION_RATE: 30, // 30% commission (default, can be overridden in admin settings) - MINIMUM_PAYOUT: 5000, // $50 minimum payout (in cents) + DEFAULT_COMMISSION_RATE: 30, // Default commission rate percentage + MINIMUM_PAYOUT: 5000, // $50 minimum payout (in cents) - for Payment Link affiliates only + DEFAULT_MINIMUM_PAYOUT: 5000, // Default minimum payout in cents AFFILIATE_CODE_LENGTH: 8, // Length of generated affiliate codes AFFILIATE_CODE_RETRY_ATTEMPTS: 10, // Max attempts to generate unique code MAX_PURCHASE_AMOUNT: 100_000_00, // $100,000 in cents - reasonable max for single purchase @@ -31,6 +33,10 @@ export const AFFILIATE_CONFIG = { // App settings keys (not feature flags) export const APP_SETTING_KEYS = { AFFILIATE_COMMISSION_RATE: "AFFILIATE_COMMISSION_RATE", + AFFILIATE_MINIMUM_PAYOUT: "AFFILIATE_MINIMUM_PAYOUT", + PRICING_CURRENT_PRICE: "PRICING_CURRENT_PRICE", + PRICING_ORIGINAL_PRICE: "PRICING_ORIGINAL_PRICE", + PRICING_PROMO_LABEL: "PRICING_PROMO_LABEL", } as const; // Company information for marketing emails diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 84a281b2..3741bf4d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { ThemeToggle } from "~/components/theme-toggle"; import { Toaster } from "sonner"; import NProgress from "nprogress"; import "nprogress/nprogress.css"; +import { NuqsAdapter } from "nuqs/adapters/react"; import { shouldShowEarlyAccessFn } from "~/fn/early-access"; import { useAnalytics } from "~/hooks/use-analytics"; import { publicEnv } from "~/utils/env-public"; @@ -239,7 +240,8 @@ function RootDocument({ children }: { children: React.ReactNode }) { `} - + + {/* Configurable banner */} {showBanner && (
@@ -271,7 +273,8 @@ function RootDocument({ children }: { children: React.ReactNode }) { {showDevMenu && } - + + ); diff --git a/src/utils/env.ts b/src/utils/env.ts index 2079233b..580498fe 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -36,6 +36,9 @@ 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, + // 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"), @@ -43,4 +46,6 @@ export const env = { AWS_SES_SECRET_ACCESS_KEY: testFallback(process.env.AWS_SES_SECRET_ACCESS_KEY, "test-ses-secret-key", "AWS_SES_SECRET_ACCESS_KEY"), AWS_SES_REGION: process.env.AWS_SES_REGION || "us-east-1", FROM_EMAIL_ADDRESS: testFallback(process.env.FROM_EMAIL_ADDRESS, "test@example.com", "FROM_EMAIL_ADDRESS"), + // System user ID for automatic operations (e.g., automatic payouts) + SYSTEM_USER_ID: parseInt(process.env.SYSTEM_USER_ID || "1", 10), }; 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(), From aa87fd425157631f51a27f8c35e50f2784bf8e16 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Fri, 26 Dec 2025 21:57:19 +0000 Subject: [PATCH 07/28] feat(affiliates): Add pagination to Activity timeline with Load older button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Limit initial load to 10 referrals and 10 payouts - Add "Load older activity" button to fetch more - Use useEffect for proper state management - Track hasMore flags from paginated responses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../affiliate-details-sheet.tsx | 135 ++++++++++++++++-- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx index 8c58c44e..25d33b93 100644 --- a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx +++ b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sheet, @@ -66,6 +66,8 @@ const formatShortDate = (date: Date | string | null) => { .replace(" ", " '"); }; +const ACTIVITY_PAGE_SIZE = 10; + export function AffiliateDetailsSheet({ affiliate, open, @@ -75,20 +77,104 @@ export function AffiliateDetailsSheet({ const [editingRate, setEditingRate] = useState(false); const [newCommissionRate, setNewCommissionRate] = useState(""); + // Pagination state for activity timeline + const [referralsOffset, setReferralsOffset] = useState(0); + const [payoutsOffset, setPayoutsOffset] = useState(0); + const [allReferrals, setAllReferrals] = useState>([]); + const [allPayouts, setAllPayouts] = useState>([]); + const [hasMoreReferrals, setHasMoreReferrals] = useState(false); + const [hasMorePayouts, setHasMorePayouts] = useState(false); + + // Reset state when affiliate changes or sheet closes + const affiliateId = affiliate?.id; + + useEffect(() => { + if (!open || !affiliateId) { + setReferralsOffset(0); + setPayoutsOffset(0); + setAllReferrals([]); + setAllPayouts([]); + setHasMoreReferrals(false); + setHasMorePayouts(false); + } + }, [open, affiliateId]); + // Fetch payout history when sheet is open - const { data: payouts } = useQuery({ - queryKey: ["affiliatePayouts", affiliate?.id], - queryFn: () => adminGetAffiliatePayoutsFn({ data: { affiliateId: affiliate!.id } }), - enabled: open && !!affiliate?.id, + const { data: payoutsData, isFetching: isFetchingPayouts } = useQuery({ + queryKey: ["affiliatePayouts", affiliateId, payoutsOffset], + queryFn: () => adminGetAffiliatePayoutsFn({ + data: { affiliateId: affiliateId!, limit: ACTIVITY_PAGE_SIZE, offset: payoutsOffset } + }), + enabled: open && !!affiliateId, }); // Fetch referral/conversion history when sheet is open - const { data: referrals } = useQuery({ - queryKey: ["affiliateReferrals", affiliate?.id], - queryFn: () => adminGetAffiliateReferralsFn({ data: { affiliateId: affiliate!.id } }), - enabled: open && !!affiliate?.id, + const { data: referralsData, isFetching: isFetchingReferrals } = useQuery({ + queryKey: ["affiliateReferrals", affiliateId, referralsOffset], + queryFn: () => adminGetAffiliateReferralsFn({ + data: { affiliateId: affiliateId!, limit: ACTIVITY_PAGE_SIZE, offset: referralsOffset } + }), + enabled: open && !!affiliateId, }); + // Update accumulated referrals when new data arrives + useEffect(() => { + if (referralsData?.items) { + const newItems = referralsData.items; + if (referralsOffset === 0) { + setAllReferrals(newItems as typeof allReferrals); + } else { + setAllReferrals(prev => { + const existingIds = new Set(prev.map(r => r.id)); + const uniqueNew = newItems.filter((r: { id: number }) => !existingIds.has(r.id)); + return [...prev, ...(uniqueNew as typeof allReferrals)]; + }); + } + setHasMoreReferrals(referralsData.hasMore); + } + }, [referralsData, referralsOffset]); + + // Update accumulated payouts when new data arrives + useEffect(() => { + if (payoutsData?.items) { + const newItems = payoutsData.items; + if (payoutsOffset === 0) { + setAllPayouts(newItems as typeof allPayouts); + } else { + setAllPayouts(prev => { + const existingIds = new Set(prev.map(p => p.id)); + const uniqueNew = newItems.filter((p: { id: number }) => !existingIds.has(p.id)); + return [...prev, ...(uniqueNew as typeof allPayouts)]; + }); + } + setHasMorePayouts(payoutsData.hasMore); + } + }, [payoutsData, payoutsOffset]); + + const loadMoreActivity = () => { + if (hasMoreReferrals) { + setReferralsOffset(prev => prev + ACTIVITY_PAGE_SIZE); + } + if (hasMorePayouts) { + setPayoutsOffset(prev => prev + ACTIVITY_PAGE_SIZE); + } + }; + + const hasMoreActivity = hasMoreReferrals || hasMorePayouts; + const isLoadingMore = isFetchingReferrals || isFetchingPayouts; + const toggleStatusMutation = useMutation({ mutationFn: adminToggleAffiliateStatusFn, onSuccess: () => { @@ -356,8 +442,8 @@ export function AffiliateDetailsSheet({ {/* Timeline items */}
{/* Real referral/conversion history */} - {referrals?.map((referral) => ( -
+ {allReferrals.map((referral) => ( +

@@ -374,8 +460,8 @@ export function AffiliateDetailsSheet({ ))} {/* Real payout history */} - {payouts?.map((payout) => ( -

+ {allPayouts.map((payout) => ( +
{formatDate(affiliate.createdAt)}

+ + {/* Load older button */} + {hasMoreActivity && ( +
+
{/* Spacer for alignment */} + +
+ )}
From 03371e840d3014837924f22c0cf59e94b3d08cc4 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Fri, 26 Dec 2025 22:01:49 +0000 Subject: [PATCH 08/28] fix(affiliates): Simplify Activity timeline data fetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove complex pagination state management that caused issues - Use direct data from queries instead of accumulated state - Show "+ more activity..." indicator when there's more data - Full pagination can be added later if needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../affiliate-details-sheet.tsx | 126 +++--------------- 1 file changed, 18 insertions(+), 108 deletions(-) diff --git a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx index 25d33b93..7cdaf057 100644 --- a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx +++ b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sheet, @@ -66,8 +66,6 @@ const formatShortDate = (date: Date | string | null) => { .replace(" ", " '"); }; -const ACTIVITY_PAGE_SIZE = 10; - export function AffiliateDetailsSheet({ affiliate, open, @@ -77,103 +75,28 @@ export function AffiliateDetailsSheet({ const [editingRate, setEditingRate] = useState(false); const [newCommissionRate, setNewCommissionRate] = useState(""); - // Pagination state for activity timeline - const [referralsOffset, setReferralsOffset] = useState(0); - const [payoutsOffset, setPayoutsOffset] = useState(0); - const [allReferrals, setAllReferrals] = useState>([]); - const [allPayouts, setAllPayouts] = useState>([]); - const [hasMoreReferrals, setHasMoreReferrals] = useState(false); - const [hasMorePayouts, setHasMorePayouts] = useState(false); - - // Reset state when affiliate changes or sheet closes - const affiliateId = affiliate?.id; - - useEffect(() => { - if (!open || !affiliateId) { - setReferralsOffset(0); - setPayoutsOffset(0); - setAllReferrals([]); - setAllPayouts([]); - setHasMoreReferrals(false); - setHasMorePayouts(false); - } - }, [open, affiliateId]); - // Fetch payout history when sheet is open - const { data: payoutsData, isFetching: isFetchingPayouts } = useQuery({ - queryKey: ["affiliatePayouts", affiliateId, payoutsOffset], + const { data: payoutsData } = useQuery({ + queryKey: ["affiliatePayouts", affiliate?.id], queryFn: () => adminGetAffiliatePayoutsFn({ - data: { affiliateId: affiliateId!, limit: ACTIVITY_PAGE_SIZE, offset: payoutsOffset } + data: { affiliateId: affiliate!.id } }), - enabled: open && !!affiliateId, + enabled: open && !!affiliate?.id, }); // Fetch referral/conversion history when sheet is open - const { data: referralsData, isFetching: isFetchingReferrals } = useQuery({ - queryKey: ["affiliateReferrals", affiliateId, referralsOffset], + const { data: referralsData } = useQuery({ + queryKey: ["affiliateReferrals", affiliate?.id], queryFn: () => adminGetAffiliateReferralsFn({ - data: { affiliateId: affiliateId!, limit: ACTIVITY_PAGE_SIZE, offset: referralsOffset } + data: { affiliateId: affiliate!.id } }), - enabled: open && !!affiliateId, + enabled: open && !!affiliate?.id, }); - // Update accumulated referrals when new data arrives - useEffect(() => { - if (referralsData?.items) { - const newItems = referralsData.items; - if (referralsOffset === 0) { - setAllReferrals(newItems as typeof allReferrals); - } else { - setAllReferrals(prev => { - const existingIds = new Set(prev.map(r => r.id)); - const uniqueNew = newItems.filter((r: { id: number }) => !existingIds.has(r.id)); - return [...prev, ...(uniqueNew as typeof allReferrals)]; - }); - } - setHasMoreReferrals(referralsData.hasMore); - } - }, [referralsData, referralsOffset]); - - // Update accumulated payouts when new data arrives - useEffect(() => { - if (payoutsData?.items) { - const newItems = payoutsData.items; - if (payoutsOffset === 0) { - setAllPayouts(newItems as typeof allPayouts); - } else { - setAllPayouts(prev => { - const existingIds = new Set(prev.map(p => p.id)); - const uniqueNew = newItems.filter((p: { id: number }) => !existingIds.has(p.id)); - return [...prev, ...(uniqueNew as typeof allPayouts)]; - }); - } - setHasMorePayouts(payoutsData.hasMore); - } - }, [payoutsData, payoutsOffset]); - - const loadMoreActivity = () => { - if (hasMoreReferrals) { - setReferralsOffset(prev => prev + ACTIVITY_PAGE_SIZE); - } - if (hasMorePayouts) { - setPayoutsOffset(prev => prev + ACTIVITY_PAGE_SIZE); - } - }; - - const hasMoreActivity = hasMoreReferrals || hasMorePayouts; - const isLoadingMore = isFetchingReferrals || isFetchingPayouts; + // Simple data access - pagination can be added later if needed + const allReferrals = referralsData?.items ?? []; + const allPayouts = payoutsData?.items ?? []; + const hasMoreActivity = (referralsData?.hasMore ?? false) || (payoutsData?.hasMore ?? false); const toggleStatusMutation = useMutation({ mutationFn: adminToggleAffiliateStatusFn, @@ -500,26 +423,13 @@ export function AffiliateDetailsSheet({
- {/* Load older button */} + {/* Show indicator if there's more activity */} {hasMoreActivity && (
-
{/* Spacer for alignment */} - +
+ + + more activity... +
)}
From 78ec69480e38b86ccf61f8d731b444713bcb986b Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Fri, 26 Dec 2025 22:04:42 +0000 Subject: [PATCH 09/28] fix(affiliates): Extract .items from paginated referrals/payouts in use-case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getAffiliateReferrals and getAffiliatePayouts now return { items, hasMore } - Update getAffiliateAnalyticsUseCase to extract .items for dashboard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/use-cases/affiliates.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/use-cases/affiliates.ts b/src/use-cases/affiliates.ts index 55aa69d1..246a4b7a 100644 --- a/src/use-cases/affiliates.ts +++ b/src/use-cases/affiliates.ts @@ -721,7 +721,7 @@ export async function getAffiliateAnalyticsUseCase(userId: number) { ); } - const [stats, referrals, payouts, monthlyEarnings] = await Promise.all([ + const [stats, referralsResult, payoutsResult, monthlyEarnings] = await Promise.all([ getAffiliateStats(affiliate.id), getAffiliateReferrals(affiliate.id), getAffiliatePayouts(affiliate.id), @@ -731,8 +731,8 @@ export async function getAffiliateAnalyticsUseCase(userId: number) { return { affiliate, stats, - referrals, - payouts, + referrals: referralsResult.items, + payouts: payoutsResult.items, monthlyEarnings, }; } From 551045c69276c9e264350c9a35a0e1b16abf42db Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 18:21:36 +0000 Subject: [PATCH 10/28] feat(affiliates): Simplify dashboard UI and add Stripe account info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate Payment Information / Stripe Connect cards into single card - Show business name and account type from Stripe API - Hide Edit button when custom payment links disabled - Fix misleading messages (instant payouts, not $50 minimum for Stripe) - Add stripeAccountType column to affiliates schema - Fetch Stripe account name dynamically in dashboard use-case 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/config.ts | 3 + src/config/feature-flags.ts | 42 ++ src/data-access/affiliates.ts | 26 +- src/db/schema.ts | 1 + src/fn/affiliates.ts | 33 +- src/fn/app-settings.ts | 22 +- src/routeTree.gen.ts | 63 +- src/routes/-components/header.tsx | 4 +- src/routes/admin/-components/admin-nav.tsx | 4 +- src/routes/admin/feature-flags.tsx | 278 ++++++++ src/routes/admin/settings.tsx | 169 ----- src/routes/affiliate-dashboard.tsx | 595 ++++++++-------- src/routes/affiliate-onboarding.tsx | 673 ++++++++++++++++++ src/routes/affiliates.tsx | 464 ++---------- .../api/connect/stripe/callback/index.ts | 14 +- .../connect/stripe/oauth/callback/index.ts | 14 +- src/use-cases/affiliates.ts | 16 +- 17 files changed, 1513 insertions(+), 908 deletions(-) create mode 100644 src/routes/admin/feature-flags.tsx delete mode 100644 src/routes/admin/settings.tsx create mode 100644 src/routes/affiliate-onboarding.tsx diff --git a/src/config.ts b/src/config.ts index 8236b5b6..7cf0341f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export const AFFILIATE_CONFIG = { DEFAULT_COMMISSION_RATE: 30, // Default commission rate percentage MINIMUM_PAYOUT: 5000, // $50 minimum payout (in cents) - for Payment Link affiliates only DEFAULT_MINIMUM_PAYOUT: 5000, // Default minimum payout in cents + COOKIE_DURATION_DAYS: 30, // Attribution window for affiliate referrals AFFILIATE_CODE_LENGTH: 8, // Length of generated affiliate codes AFFILIATE_CODE_RETRY_ATTEMPTS: 10, // Max attempts to generate unique code MAX_PURCHASE_AMOUNT: 100_000_00, // $100,000 in cents - reasonable max for single purchase @@ -57,9 +58,11 @@ export { FLAGS, TARGET_MODES, FALLBACK_CONFIG, + FLAG_GROUPS, FEATURE_FLAGS_CONFIG, DISPLAYED_FLAGS, type FlagKey, type TargetMode, + type FlagGroup, type FeatureFlagUIConfig, } from "./config/feature-flags"; diff --git a/src/config/feature-flags.ts b/src/config/feature-flags.ts index 2db46af2..d11f8a6e 100644 --- a/src/config/feature-flags.ts +++ b/src/config/feature-flags.ts @@ -11,6 +11,8 @@ import { DollarSign, FileText, Newspaper, + Link, + Gift, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; @@ -21,6 +23,8 @@ export const FLAG_KEYS = [ "ADVANCED_AGENTS_FEATURE", "LAUNCH_KITS_FEATURE", "AFFILIATES_FEATURE", + "AFFILIATE_CUSTOM_PAYMENT_LINK", + "AFFILIATE_DISCOUNT_SPLIT", "BLOG_FEATURE", "NEWS_FEATURE", "VIDEO_SEGMENT_CONTENT_TABS", @@ -50,17 +54,31 @@ export const FALLBACK_CONFIG: Record = { ADVANCED_AGENTS_FEATURE: false, LAUNCH_KITS_FEATURE: true, AFFILIATES_FEATURE: true, + AFFILIATE_CUSTOM_PAYMENT_LINK: true, + AFFILIATE_DISCOUNT_SPLIT: true, BLOG_FEATURE: true, NEWS_FEATURE: true, VIDEO_SEGMENT_CONTENT_TABS: false, }; +/** Feature flag groups for visual organization */ +export const FLAG_GROUPS = { + PLATFORM: "Platform", + AI_AGENTS: "AI Agents", + LAUNCH_KITS: "Launch Kits", + AFFILIATES: "Affiliates", + CONTENT: "Content", +} as const; + +export type FlagGroup = (typeof FLAG_GROUPS)[keyof typeof FLAG_GROUPS]; + /** Feature flag UI configuration */ export interface FeatureFlagUIConfig { key: FlagKey; title: string; description: string; icon: LucideIcon; + group: FlagGroup; dependsOn?: FlagKey[]; /** If true, this flag is hidden from the admin UI */ hidden?: boolean; @@ -72,18 +90,21 @@ export const FEATURE_FLAGS_CONFIG: FeatureFlagUIConfig[] = [ title: "Early Access Mode", description: "Control whether the platform is in early access mode. When enabled, only admins can access the full site.", icon: Shield, + group: FLAG_GROUPS.PLATFORM, }, { key: "AGENTS_FEATURE", title: "Agents Feature", description: "Control whether the AI agents feature is available to users. When disabled, agent-related functionality will be hidden.", icon: Bot, + group: FLAG_GROUPS.AI_AGENTS, }, { key: "ADVANCED_AGENTS_FEATURE", title: "Advanced Agents", description: "Enable advanced AI agent capabilities like custom workflows and automation. Requires the base Agents feature.", icon: Sparkles, + group: FLAG_GROUPS.AI_AGENTS, dependsOn: ["AGENTS_FEATURE"], }, { @@ -91,30 +112,51 @@ export const FEATURE_FLAGS_CONFIG: FeatureFlagUIConfig[] = [ title: "Launch Kits Feature", description: "Control whether the launch kits feature is available to users. When disabled, launch kit functionality will be hidden.", icon: Package, + group: FLAG_GROUPS.LAUNCH_KITS, }, { key: "AFFILIATES_FEATURE", title: "Affiliates Feature", description: "Control whether the affiliate program features are available to users. When disabled, affiliate-related functionality will be hidden.", icon: DollarSign, + group: FLAG_GROUPS.AFFILIATES, + }, + { + key: "AFFILIATE_CUSTOM_PAYMENT_LINK", + title: "Affiliate Custom Payment Link", + description: "Allow affiliates to use custom payment links (PayPal, Venmo, etc.). When disabled, only Stripe Connect is available for payouts.", + icon: Link, + group: FLAG_GROUPS.AFFILIATES, + dependsOn: ["AFFILIATES_FEATURE"], + }, + { + key: "AFFILIATE_DISCOUNT_SPLIT", + title: "Affiliate Discount Split", + description: "Allow affiliates to share their commission as a customer discount. When disabled, the discount slider is hidden from the affiliate dashboard.", + icon: Gift, + group: FLAG_GROUPS.AFFILIATES, + dependsOn: ["AFFILIATES_FEATURE"], }, { key: "BLOG_FEATURE", title: "Blog Feature", description: "Control whether the blog feature is available to users. When disabled, blog-related functionality will be hidden.", icon: FileText, + group: FLAG_GROUPS.CONTENT, }, { key: "NEWS_FEATURE", title: "News Feature", description: "Control whether the news feature is available to users. When disabled, news-related functionality will be hidden.", icon: Newspaper, + group: FLAG_GROUPS.CONTENT, }, { key: "VIDEO_SEGMENT_CONTENT_TABS", title: "Video Segment Content Tabs", description: "Control whether the video segment content tabs feature is available.", icon: FileText, + group: FLAG_GROUPS.CONTENT, hidden: true, }, ]; diff --git a/src/data-access/affiliates.ts b/src/data-access/affiliates.ts index dfa42a3f..1dc4743f 100644 --- a/src/data-access/affiliates.ts +++ b/src/data-access/affiliates.ts @@ -1,4 +1,4 @@ -import { eq, desc, and, sql, gte, lt, lte, inArray, isNotNull } from "drizzle-orm"; +import { eq, desc, and, sql, gte, lt, lte, inArray, isNotNull, ne } from "drizzle-orm"; import { database } from "~/db"; import { affiliates, @@ -353,9 +353,33 @@ export async function updateAffiliateStripeAccount( stripeChargesEnabled?: boolean; stripePayoutsEnabled?: boolean; stripeDetailsSubmitted?: boolean; + stripeAccountType?: string | null; lastStripeSync?: Date; } ) { + // If assigning a Stripe account, first clear it from any other affiliate + // This handles the case where the same Stripe account was previously connected + // to a different affiliate (e.g., during testing) + if (data.stripeConnectAccountId) { + await database + .update(affiliates) + .set({ + stripeConnectAccountId: null, + stripeAccountStatus: "not_started", + stripeChargesEnabled: false, + stripePayoutsEnabled: false, + stripeDetailsSubmitted: false, + stripeAccountType: null, + updatedAt: new Date(), + }) + .where( + and( + eq(affiliates.stripeConnectAccountId, data.stripeConnectAccountId), + ne(affiliates.id, affiliateId) + ) + ); + } + const [updated] = await database .update(affiliates) .set({ diff --git a/src/db/schema.ts b/src/db/schema.ts index 650db79a..bdf90b0e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -272,6 +272,7 @@ export const affiliates = tableCreator( stripeChargesEnabled: boolean("stripeChargesEnabled").notNull().default(false), stripePayoutsEnabled: boolean("stripePayoutsEnabled").notNull().default(false), stripeDetailsSubmitted: boolean("stripeDetailsSubmitted").notNull().default(false), + stripeAccountType: text("stripeAccountType"), // express, standard, custom lastStripeSync: timestamp("lastStripeSync"), // Payout error tracking lastPayoutError: text("lastPayoutError"), diff --git a/src/fn/affiliates.ts b/src/fn/affiliates.ts index 71f0a7bb..7e6e9318 100644 --- a/src/fn/affiliates.ts +++ b/src/fn/affiliates.ts @@ -70,10 +70,39 @@ export const checkIfUserIsAffiliateFn = createServerFn() .middleware([unauthenticatedMiddleware, affiliatesFeatureMiddleware]) .handler(async ({ context }) => { if (!context.userId) { - return { isAffiliate: false }; + return { + isAffiliate: false, + isOnboardingComplete: false, + paymentMethod: null, + stripeAccountStatus: null, + hasStripeAccount: false, + }; } const affiliate = await getAffiliateByUserId(context.userId); - return { isAffiliate: !!affiliate }; + if (!affiliate) { + return { + isAffiliate: false, + isOnboardingComplete: false, + paymentMethod: null, + stripeAccountStatus: null, + hasStripeAccount: false, + }; + } + + // Check if onboarding is complete: + // - For 'link' payment method: always complete (no extra setup needed) + // - For 'stripe' payment method: complete only if Stripe account is fully active + const isOnboardingComplete = + affiliate.paymentMethod === "link" || + (affiliate.paymentMethod === "stripe" && affiliate.stripeAccountStatus === "active"); + + return { + isAffiliate: true, + isOnboardingComplete, + paymentMethod: affiliate.paymentMethod, + stripeAccountStatus: affiliate.stripeAccountStatus, + hasStripeAccount: !!affiliate.stripeConnectAccountId, + }; }); const updatePaymentMethodSchema = z.object({ diff --git a/src/fn/app-settings.ts b/src/fn/app-settings.ts index f9f69763..fbfb8ec3 100644 --- a/src/fn/app-settings.ts +++ b/src/fn/app-settings.ts @@ -179,7 +179,7 @@ export const toggleFeatureFlagFn = createServerFn({ method: "POST" }) }); /** - * Get the affiliate commission rate from app settings. + * Get the affiliate commission rate from app settings (admin only). */ export const getAffiliateCommissionRateFn = createServerFn({ method: "GET" }) .middleware([adminMiddleware]) @@ -188,6 +188,15 @@ export const getAffiliateCommissionRateFn = createServerFn({ method: "GET" }) return getAffiliateCommissionRateUseCase(); }); +/** + * Get the affiliate commission rate (public - for onboarding page). + */ +export const getPublicAffiliateCommissionRateFn = createServerFn({ method: "GET" }) + .middleware([unauthenticatedMiddleware]) + .handler(async () => { + return getAffiliateCommissionRateUseCase(); + }); + /** * Set the affiliate commission rate in app settings. */ @@ -200,7 +209,7 @@ export const setAffiliateCommissionRateFn = createServerFn({ method: "POST" }) }); /** - * Get the affiliate minimum payout from app settings. + * Get the affiliate minimum payout from app settings (admin only). */ export const getAffiliateMinimumPayoutFn = createServerFn({ method: "GET" }) .middleware([adminMiddleware]) @@ -209,6 +218,15 @@ export const getAffiliateMinimumPayoutFn = createServerFn({ method: "GET" }) return getAffiliateMinimumPayoutUseCase(); }); +/** + * Get the affiliate minimum payout (public - for onboarding page). + */ +export const getPublicAffiliateMinimumPayoutFn = createServerFn({ method: "GET" }) + .middleware([unauthenticatedMiddleware]) + .handler(async () => { + return getAffiliateMinimumPayoutUseCase(); + }); + /** * Set the affiliate minimum payout in app settings. */ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 7af05a50..36679133 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as CreateTestimonialRouteImport } from './routes/create-testimoni import { Route as CommunityRouteImport } from './routes/community' import { Route as CancelRouteImport } from './routes/cancel' import { Route as AffiliatesRouteImport } from './routes/affiliates' +import { Route as AffiliateOnboardingRouteImport } from './routes/affiliate-onboarding' import { Route as AffiliateDashboardRouteImport } from './routes/affiliate-dashboard' import { Route as AboutRouteImport } from './routes/about' import { Route as AdminRouteRouteImport } from './routes/admin/route' @@ -50,8 +51,8 @@ import { Route as AgentsSlugRouteImport } from './routes/agents/$slug' import { Route as AdminVideoProcessingRouteImport } from './routes/admin/video-processing' import { Route as AdminUtmAnalyticsRouteImport } from './routes/admin/utm-analytics' import { Route as AdminUsersRouteImport } from './routes/admin/users' -import { Route as AdminSettingsRouteImport } from './routes/admin/settings' import { Route as AdminPricingRouteImport } from './routes/admin/pricing' +import { Route as AdminFeatureFlagsRouteImport } from './routes/admin/feature-flags' import { Route as AdminCommentsRouteImport } from './routes/admin/comments' import { Route as AdminAnalyticsRouteImport } from './routes/admin/analytics' import { Route as AdminAffiliatesRouteImport } from './routes/admin/affiliates' @@ -180,6 +181,11 @@ const AffiliatesRoute = AffiliatesRouteImport.update({ path: '/affiliates', getParentRoute: () => rootRouteImport, } as any) +const AffiliateOnboardingRoute = AffiliateOnboardingRouteImport.update({ + id: '/affiliate-onboarding', + path: '/affiliate-onboarding', + getParentRoute: () => rootRouteImport, +} as any) const AffiliateDashboardRoute = AffiliateDashboardRouteImport.update({ id: '/affiliate-dashboard', path: '/affiliate-dashboard', @@ -295,16 +301,16 @@ const AdminUsersRoute = AdminUsersRouteImport.update({ path: '/users', getParentRoute: () => AdminRouteRoute, } as any) -const AdminSettingsRoute = AdminSettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => AdminRouteRoute, -} as any) const AdminPricingRoute = AdminPricingRouteImport.update({ id: '/pricing', path: '/pricing', getParentRoute: () => AdminRouteRoute, } as any) +const AdminFeatureFlagsRoute = AdminFeatureFlagsRouteImport.update({ + id: '/feature-flags', + path: '/feature-flags', + getParentRoute: () => AdminRouteRoute, +} as any) const AdminCommentsRoute = AdminCommentsRouteImport.update({ id: '/comments', path: '/comments', @@ -504,6 +510,7 @@ export interface FileRoutesByFullPath { '/admin': typeof AdminRouteRouteWithChildren '/about': typeof AboutRoute '/affiliate-dashboard': typeof AffiliateDashboardRoute + '/affiliate-onboarding': typeof AffiliateOnboardingRoute '/affiliates': typeof AffiliatesRoute '/cancel': typeof CancelRoute '/community': typeof CommunityRoute @@ -527,8 +534,8 @@ export interface FileRoutesByFullPath { '/admin/affiliates': typeof AdminAffiliatesRoute '/admin/analytics': typeof AdminAnalyticsRoute '/admin/comments': typeof AdminCommentsRoute + '/admin/feature-flags': typeof AdminFeatureFlagsRoute '/admin/pricing': typeof AdminPricingRoute - '/admin/settings': typeof AdminSettingsRoute '/admin/users': typeof AdminUsersRoute '/admin/utm-analytics': typeof AdminUtmAnalyticsRoute '/admin/video-processing': typeof AdminVideoProcessingRoute @@ -586,6 +593,7 @@ export interface FileRoutesByTo { '/admin': typeof AdminRouteRouteWithChildren '/about': typeof AboutRoute '/affiliate-dashboard': typeof AffiliateDashboardRoute + '/affiliate-onboarding': typeof AffiliateOnboardingRoute '/affiliates': typeof AffiliatesRoute '/cancel': typeof CancelRoute '/community': typeof CommunityRoute @@ -607,8 +615,8 @@ export interface FileRoutesByTo { '/admin/affiliates': typeof AdminAffiliatesRoute '/admin/analytics': typeof AdminAnalyticsRoute '/admin/comments': typeof AdminCommentsRoute + '/admin/feature-flags': typeof AdminFeatureFlagsRoute '/admin/pricing': typeof AdminPricingRoute - '/admin/settings': typeof AdminSettingsRoute '/admin/users': typeof AdminUsersRoute '/admin/utm-analytics': typeof AdminUtmAnalyticsRoute '/admin/video-processing': typeof AdminVideoProcessingRoute @@ -666,6 +674,7 @@ export interface FileRoutesById { '/admin': typeof AdminRouteRouteWithChildren '/about': typeof AboutRoute '/affiliate-dashboard': typeof AffiliateDashboardRoute + '/affiliate-onboarding': typeof AffiliateOnboardingRoute '/affiliates': typeof AffiliatesRoute '/cancel': typeof CancelRoute '/community': typeof CommunityRoute @@ -689,8 +698,8 @@ export interface FileRoutesById { '/admin/affiliates': typeof AdminAffiliatesRoute '/admin/analytics': typeof AdminAnalyticsRoute '/admin/comments': typeof AdminCommentsRoute + '/admin/feature-flags': typeof AdminFeatureFlagsRoute '/admin/pricing': typeof AdminPricingRoute - '/admin/settings': typeof AdminSettingsRoute '/admin/users': typeof AdminUsersRoute '/admin/utm-analytics': typeof AdminUtmAnalyticsRoute '/admin/video-processing': typeof AdminVideoProcessingRoute @@ -750,6 +759,7 @@ export interface FileRouteTypes { | '/admin' | '/about' | '/affiliate-dashboard' + | '/affiliate-onboarding' | '/affiliates' | '/cancel' | '/community' @@ -773,8 +783,8 @@ export interface FileRouteTypes { | '/admin/affiliates' | '/admin/analytics' | '/admin/comments' + | '/admin/feature-flags' | '/admin/pricing' - | '/admin/settings' | '/admin/users' | '/admin/utm-analytics' | '/admin/video-processing' @@ -832,6 +842,7 @@ export interface FileRouteTypes { | '/admin' | '/about' | '/affiliate-dashboard' + | '/affiliate-onboarding' | '/affiliates' | '/cancel' | '/community' @@ -853,8 +864,8 @@ export interface FileRouteTypes { | '/admin/affiliates' | '/admin/analytics' | '/admin/comments' + | '/admin/feature-flags' | '/admin/pricing' - | '/admin/settings' | '/admin/users' | '/admin/utm-analytics' | '/admin/video-processing' @@ -911,6 +922,7 @@ export interface FileRouteTypes { | '/admin' | '/about' | '/affiliate-dashboard' + | '/affiliate-onboarding' | '/affiliates' | '/cancel' | '/community' @@ -934,8 +946,8 @@ export interface FileRouteTypes { | '/admin/affiliates' | '/admin/analytics' | '/admin/comments' + | '/admin/feature-flags' | '/admin/pricing' - | '/admin/settings' | '/admin/users' | '/admin/utm-analytics' | '/admin/video-processing' @@ -994,6 +1006,7 @@ export interface RootRouteChildren { AdminRouteRoute: typeof AdminRouteRouteWithChildren AboutRoute: typeof AboutRoute AffiliateDashboardRoute: typeof AffiliateDashboardRoute + AffiliateOnboardingRoute: typeof AffiliateOnboardingRoute AffiliatesRoute: typeof AffiliatesRoute CancelRoute: typeof CancelRoute CommunityRoute: typeof CommunityRoute @@ -1169,6 +1182,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AffiliatesRouteImport parentRoute: typeof rootRouteImport } + '/affiliate-onboarding': { + id: '/affiliate-onboarding' + path: '/affiliate-onboarding' + fullPath: '/affiliate-onboarding' + preLoaderRoute: typeof AffiliateOnboardingRouteImport + parentRoute: typeof rootRouteImport + } '/affiliate-dashboard': { id: '/affiliate-dashboard' path: '/affiliate-dashboard' @@ -1330,13 +1350,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminUsersRouteImport parentRoute: typeof AdminRouteRoute } - '/admin/settings': { - id: '/admin/settings' - path: '/settings' - fullPath: '/admin/settings' - preLoaderRoute: typeof AdminSettingsRouteImport - parentRoute: typeof AdminRouteRoute - } '/admin/pricing': { id: '/admin/pricing' path: '/pricing' @@ -1344,6 +1357,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminPricingRouteImport parentRoute: typeof AdminRouteRoute } + '/admin/feature-flags': { + id: '/admin/feature-flags' + path: '/feature-flags' + fullPath: '/admin/feature-flags' + preLoaderRoute: typeof AdminFeatureFlagsRouteImport + parentRoute: typeof AdminRouteRoute + } '/admin/comments': { id: '/admin/comments' path: '/comments' @@ -1652,8 +1672,8 @@ interface AdminRouteRouteChildren { AdminAffiliatesRoute: typeof AdminAffiliatesRoute AdminAnalyticsRoute: typeof AdminAnalyticsRoute AdminCommentsRoute: typeof AdminCommentsRoute + AdminFeatureFlagsRoute: typeof AdminFeatureFlagsRoute AdminPricingRoute: typeof AdminPricingRoute - AdminSettingsRoute: typeof AdminSettingsRoute AdminUsersRoute: typeof AdminUsersRoute AdminUtmAnalyticsRoute: typeof AdminUtmAnalyticsRoute AdminVideoProcessingRoute: typeof AdminVideoProcessingRoute @@ -1675,8 +1695,8 @@ const AdminRouteRouteChildren: AdminRouteRouteChildren = { AdminAffiliatesRoute: AdminAffiliatesRoute, AdminAnalyticsRoute: AdminAnalyticsRoute, AdminCommentsRoute: AdminCommentsRoute, + AdminFeatureFlagsRoute: AdminFeatureFlagsRoute, AdminPricingRoute: AdminPricingRoute, - AdminSettingsRoute: AdminSettingsRoute, AdminUsersRoute: AdminUsersRoute, AdminUtmAnalyticsRoute: AdminUtmAnalyticsRoute, AdminVideoProcessingRoute: AdminVideoProcessingRoute, @@ -1713,6 +1733,7 @@ const rootRouteChildren: RootRouteChildren = { AdminRouteRoute: AdminRouteRouteWithChildren, AboutRoute: AboutRoute, AffiliateDashboardRoute: AffiliateDashboardRoute, + AffiliateOnboardingRoute: AffiliateOnboardingRoute, AffiliatesRoute: AffiliatesRoute, CancelRoute: CancelRoute, CommunityRoute: CommunityRoute, diff --git a/src/routes/-components/header.tsx b/src/routes/-components/header.tsx index c348363b..df9b8b56 100644 --- a/src/routes/-components/header.tsx +++ b/src/routes/-components/header.tsx @@ -237,8 +237,8 @@ const ADMIN_MENU_ITEMS: AdminMenuItem[] = [ // System { - to: "/admin/settings", - label: "Settings", + to: "/admin/feature-flags", + label: "Feature Flags", icon: Settings, category: "system", }, diff --git a/src/routes/admin/-components/admin-nav.tsx b/src/routes/admin/-components/admin-nav.tsx index 4bd691d3..fd530331 100644 --- a/src/routes/admin/-components/admin-nav.tsx +++ b/src/routes/admin/-components/admin-nav.tsx @@ -123,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/feature-flags.tsx b/src/routes/admin/feature-flags.tsx new file mode 100644 index 00000000..8e667338 --- /dev/null +++ b/src/routes/admin/feature-flags.tsx @@ -0,0 +1,278 @@ +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]: () => Promise.resolve(undefined), +}; + +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/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 132c7f62..c19a0507 100644 --- a/src/routes/affiliate-dashboard.tsx +++ b/src/routes/affiliate-dashboard.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; import { assertFeatureEnabled } from "~/lib/feature-flags"; import { useSuspenseQuery, @@ -54,6 +54,7 @@ import { } from "lucide-react"; import { cn } from "~/lib/utils"; import { env } from "~/utils/env"; +import { AFFILIATE_CONFIG } from "~/config"; import { Dialog, DialogContent, @@ -87,6 +88,7 @@ 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"; @@ -172,24 +174,39 @@ 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 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 and feature flags in parallel + const [data, discountSplitEnabled, customPaymentLinkEnabled] = 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" } }), + }), + ]); - // 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: data, + discountSplitEnabled, + customPaymentLinkEnabled, + }; }, component: AffiliateDashboard, }); @@ -198,19 +215,21 @@ function AffiliateDashboard() { const loaderData = Route.useLoaderData(); // If user is not an affiliate, show error message - if (!loaderData.isAffiliate) { + if (!loaderData.isAffiliate || !loaderData.dashboard) { return ; } + // Use the dashboard data and feature flags from loader + const dashboard = loaderData.dashboard; + const discountSplitEnabled = loaderData.discountSplitEnabled ?? true; + const customPaymentLinkEnabled = loaderData.customPaymentLinkEnabled ?? true; + 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(loaderData.dashboard.affiliate.discountRate); - - // Use the dashboard data from loader - const dashboard = loaderData.dashboard; + const [localDiscountRate, setLocalDiscountRate] = useState(dashboard.affiliate.discountRate); // Sync local discount rate with server data when it changes useEffect(() => { @@ -371,6 +390,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 */} @@ -400,7 +454,7 @@ function AffiliateDashboard() {
- 30-day cookie duration + {AFFILIATE_CONFIG.COOKIE_DURATION_DAYS}-day cookie duration
@@ -411,256 +465,8 @@ function AffiliateDashboard() { - {/* Stripe Connect Status Card - Only show for Stripe payment method */} - {dashboard.affiliate.paymentMethod === "stripe" && ( - - - {/* Glow effect on hover */} -
- -
-
- - - Stripe Connect - - - Automatic payouts via Stripe Connect - -
-
- {(dashboard.affiliate.stripeAccountStatus === "onboarding" || - dashboard.affiliate.stripeAccountStatus === "active" || - dashboard.affiliate.stripeAccountStatus === "restricted") && ( - - )} - {dashboard.affiliate.stripeAccountStatus === "active" && dashboard.affiliate.stripePayoutsEnabled && ( - - - Connected - - )} -
-
-
- - {/* 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. -

-
-
-
- )} - - {/* Account Status Display */} - {dashboard.affiliate.stripeAccountStatus === "not_started" && ( -
-
-
- -
-
-

Connect Your Stripe Account

-

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

-
-
- -
- )} - - {dashboard.affiliate.stripeAccountStatus === "onboarding" && ( -
-
-
- -
-
-

Complete Your Onboarding

-

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

-
-
- - - Complete Onboarding - -
- )} - - {(dashboard.affiliate.stripeAccountStatus === "active" || dashboard.affiliate.stripeAccountStatus === "restricted") && ( -
- {/* Status Badges */} -
-
- {dashboard.affiliate.stripePayoutsEnabled ? ( - - ) : ( - - )} - Payouts {dashboard.affiliate.stripePayoutsEnabled ? "Enabled" : "Disabled"} -
-
- {dashboard.affiliate.stripeChargesEnabled ? ( - - ) : ( - - )} - Charges {dashboard.affiliate.stripeChargesEnabled ? "Enabled" : "Disabled"} -
- {dashboard.affiliate.stripeDetailsSubmitted && ( -
- - Details Submitted -
- )} -
- - {/* Restricted Account Warning */} - {dashboard.affiliate.stripeAccountStatus === "restricted" && ( -
-
- -

Account Restricted

-
-

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

-
- )} - - {/* Auto-Payout Info */} - {dashboard.affiliate.stripePayoutsEnabled && ( -
-
- -

Automatic Payouts Active

-
-

- Payouts are automatically processed when your balance reaches $50 -

-
- )} - - {/* Last Sync Time */} - {dashboard.affiliate.lastStripeSync && ( -
- - Last synced: {formatDate(dashboard.affiliate.lastStripeSync)} -
- )} - - {/* Disconnect Stripe Button */} -
- - - - - - - Disconnect Stripe Connect? - - This will disconnect your Stripe Connect account and switch you back to manual payouts via a payment link. You can reconnect Stripe Connect at any time. - - - - Cancel - disconnectStripeAccountMutation.mutate({ data: {} })} - disabled={disconnectStripeAccountMutation.isPending} - className="bg-red-600 text-white hover:bg-red-700" - > - {disconnectStripeAccountMutation.isPending ? "Disconnecting..." : "Disconnect"} - - - - -
-
- )} -
-
-
- )} - - {/* Share the Benefit Card */} + {/* Share the Benefit Card - only shown when discount split feature is enabled */} + {discountSplitEnabled && (
@@ -740,6 +546,7 @@ function AffiliateDashboard() {
+ )} {/* Stats Grid */}
+ {customPaymentLinkEnabled && ( - + {customPaymentLinkEnabled && ( + + )}

- Automatic payouts will be enabled once Stripe Connect is configured + {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"}

)} @@ -975,10 +789,12 @@ function AffiliateDashboard() {
+ )}
- -
+ + {/* Payment Method Badge */} +
Payment Method: @@ -989,38 +805,189 @@ function AffiliateDashboard() { : "Payment Link"}
+ {/* Refresh Button - only for onboarding/restricted states */} + {dashboard.affiliate.paymentMethod === "stripe" && + (dashboard.affiliate.stripeAccountStatus === "onboarding" || + dashboard.affiliate.stripeAccountStatus === "restricted") && ( + + )} +
- {dashboard.affiliate.paymentMethod === "link" && dashboard.affiliate.paymentLink ? ( - - ) : dashboard.affiliate.paymentMethod === "stripe" ? ( -
-

- - Stripe Connect integration pending - automatic payouts will be enabled once configured -

-
- ) : null} + {/* Payment Link Info (for link method) */} + {dashboard.affiliate.paymentMethod === "link" && 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:{" "} $50.00
-
+ )}
diff --git a/src/routes/affiliate-onboarding.tsx b/src/routes/affiliate-onboarding.tsx new file mode 100644 index 00000000..5ddd337c --- /dev/null +++ b/src/routes/affiliate-onboarding.tsx @@ -0,0 +1,673 @@ +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 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.step) return search.step; + 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 + if (loaderData.isAffiliate) { + return "payment-method"; + } + + return "terms"; // New users start with 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 82d6fe02..08a4d7e6 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 { 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 { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; -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,90 +13,46 @@ import { Clock, Shield, Award, - CheckCircle, ArrowRight, Zap, BarChart3, - CreditCard, } from "lucide-react"; -import { cn } from "~/lib/utils"; - -const affiliateFormSchema = z.object({ - paymentMethod: z.enum(["link", "stripe"]), - paymentLink: z.string().optional(), - agreedToTerms: z.boolean().refine((val) => val === true, { - message: "You must agree to the terms of service", - }), -}).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 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 [affiliateCheck, 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)); + + 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: { - paymentMethod: "link", - paymentLink: "", - agreedToTerms: false, - }, - }); - - const paymentMethod = form.watch("paymentMethod"); - - 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 ( @@ -138,7 +71,7 @@ function AffiliatesPage() { ); } - if (affiliateStatus?.isAffiliate) { + if (isAffiliate && isOnboardingComplete) { return (

@@ -158,6 +91,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 */} @@ -177,7 +130,7 @@ function AffiliatesPage() {
- Earn 30% Commission + Earn {commissionRate}% Commission

@@ -196,19 +149,19 @@ function AffiliatesPage() {
-

30% 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.

@@ -302,292 +255,23 @@ function AffiliatesPage() {

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

- Join the Program +
+

+ Ready to Start Earning?

- -
- - {/* Payment Method Selection */} - ( - - How would you like to receive payouts? - - - - - - - - )} - /> - - {/* Conditional UI based on payment method */} - {paymentMethod === "link" ? ( - ( - - Payment Link - - - - - Enter your PayPal, Venmo, or other payment link. - - - - )} - /> - ) : ( -
-
- -

Stripe Connect

-
-

- After joining, you'll be able to connect your Stripe account from the dashboard - to receive payouts directly to your bank account. -

-
-
- -

Automatic Payouts

-
-

- When your balance reaches $50, payouts are automatically processed to your connected Stripe account - no manual requests needed! -

-
-
- )} - - ( - - - - -
- - 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 30% 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. -

-
-
-
-
-
-
-
- -
-
- )} - /> - - - - +

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

+ + + Become an Affiliate + +

@@ -607,7 +291,7 @@ function AffiliatesPage() {
- $60 + Up to ${maxCommissionPerSale}

Per sale commission

diff --git a/src/routes/api/connect/stripe/callback/index.ts b/src/routes/api/connect/stripe/callback/index.ts index 5e6c55fa..22196145 100644 --- a/src/routes/api/connect/stripe/callback/index.ts +++ b/src/routes/api/connect/stripe/callback/index.ts @@ -9,6 +9,7 @@ import { import { determineStripeAccountStatus } from "~/utils/stripe-status"; const AFTER_CONNECT_URL = "/affiliate-dashboard"; +const ONBOARDING_COMPLETE_URL = "/affiliate-onboarding?step=complete"; /** * Stripe Connect OAuth callback route. @@ -42,6 +43,7 @@ export const Route = createFileRoute("/api/connect/stripe/callback/")({ 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 if (!state || !storedState || state !== storedState) { @@ -51,6 +53,9 @@ export const Route = createFileRoute("/api/connect/stripe/callback/")({ // Clear cookies deleteCookie("stripe_connect_state"); deleteCookie("stripe_connect_affiliate_id"); + if (onboardingInProgress) { + deleteCookie("affiliate_onboarding"); + } try { // Require authentication @@ -86,13 +91,18 @@ export const Route = createFileRoute("/api/connect/stripe/callback/")({ stripeChargesEnabled: account.charges_enabled ?? false, stripePayoutsEnabled: account.payouts_enabled ?? false, stripeDetailsSubmitted: account.details_submitted ?? false, + stripeAccountType: account.type ?? null, lastStripeSync: new Date(), }); - // Redirect to affiliate dashboard + // 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: AFTER_CONNECT_URL }, + headers: { Location: redirectUrl }, }); } catch (error) { console.error("Stripe Connect callback error:", error); diff --git a/src/routes/api/connect/stripe/oauth/callback/index.ts b/src/routes/api/connect/stripe/oauth/callback/index.ts index 07f89993..b2e75213 100644 --- a/src/routes/api/connect/stripe/oauth/callback/index.ts +++ b/src/routes/api/connect/stripe/oauth/callback/index.ts @@ -9,6 +9,7 @@ import { import { determineStripeAccountStatus, StripeAccountStatus } from "~/utils/stripe-status"; const AFTER_CONNECT_URL = "/affiliate-dashboard"; +const ONBOARDING_COMPLETE_URL = "/affiliate-onboarding?step=complete"; /** * Stripe OAuth callback route for connecting existing Stripe accounts. @@ -39,11 +40,15 @@ export const Route = createFileRoute("/api/connect/stripe/oauth/callback/")({ const storedState = getCookie("stripe_oauth_state") ?? null; const storedAffiliateId = getCookie("stripe_oauth_affiliate_id") ?? null; + const onboardingInProgress = getCookie("affiliate_onboarding") ?? null; // Clear cookies deleteCookie("stripe_oauth_state"); deleteCookie("stripe_oauth_affiliate_id"); deleteCookie("stripe_oauth_type"); + if (onboardingInProgress) { + deleteCookie("affiliate_onboarding"); + } // Handle OAuth errors (user denied, etc.) if (error) { @@ -105,13 +110,18 @@ export const Route = createFileRoute("/api/connect/stripe/oauth/callback/")({ stripeChargesEnabled: account.charges_enabled ?? false, stripePayoutsEnabled: account.payouts_enabled ?? false, stripeDetailsSubmitted: account.details_submitted ?? false, + stripeAccountType: account.type ?? null, lastStripeSync: new Date(), }); - // Redirect to affiliate dashboard with success message + // 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: `${AFTER_CONNECT_URL}?connected=true` }, + headers: { Location: redirectUrl }, }); } catch (err) { console.error("Stripe OAuth callback error:", err); diff --git a/src/use-cases/affiliates.ts b/src/use-cases/affiliates.ts index 246a4b7a..325783f7 100644 --- a/src/use-cases/affiliates.ts +++ b/src/use-cases/affiliates.ts @@ -721,6 +721,17 @@ export async function getAffiliateAnalyticsUseCase(userId: number) { ); } + // 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), @@ -729,7 +740,10 @@ export async function getAffiliateAnalyticsUseCase(userId: number) { ]); return { - affiliate, + affiliate: { + ...affiliate, + stripeAccountName, // Add fetched name from Stripe API + }, stats, referrals: referralsResult.items, payouts: payoutsResult.items, From a3f2148f9612bdcecd23df555647f1b1c412c167 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 18:26:50 +0000 Subject: [PATCH 11/28] chore(db): Add migration for stripeAccountType column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- drizzle/0050_gorgeous_kulan_gath.sql | 28 + drizzle/meta/0050_snapshot.json | 4507 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + 3 files changed, 4542 insertions(+) create mode 100644 drizzle/0050_gorgeous_kulan_gath.sql create mode 100644 drizzle/meta/0050_snapshot.json 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/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/_journal.json b/drizzle/meta/_journal.json index ed4c4329..bf36dce5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1766615388060, "tag": "0049_complete_groot", "breakpoints": true + }, + { + "idx": 50, + "version": "7", + "when": 1766859824038, + "tag": "0050_gorgeous_kulan_gath", + "breakpoints": true } ] } \ No newline at end of file From 72ba09f1c6116f3c9da151fd927146130dbe7e6b Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 18:51:43 +0000 Subject: [PATCH 12/28] refactor(logging): Add extensible server function logger with scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract logMiddleware to dedicated server-logger.ts module - Add readable function names instead of base64 encoded IDs - Add configurable scopes (default, payments, auth, affiliates, etc.) - Support both backward compat (.middleware([logMiddleware])) and explicit scopes (.middleware([logMiddleware(LOG_SCOPES.PAYMENTS)])) - Fix URL-safe base64 decoding for function IDs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/lib/auth.ts | 14 +------- src/lib/server-logger.ts | 70 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 src/lib/server-logger.ts diff --git a/src/lib/auth.ts b/src/lib/auth.ts index cbf63018..4cc89542 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,24 +2,12 @@ import { createMiddleware } from "@tanstack/react-start"; import { validateRequest } from "~/utils/auth"; import { redirect } from "@tanstack/react-router"; import { type User } from "~/db/schema"; +import { logMiddleware } from "./server-logger"; export function isAdmin(user: User | null) { return user?.isAdmin ?? false; } -export const logMiddleware = createMiddleware({ type: "function" }).server( - async ({ next, context, functionId }) => { - const now = Date.now(); - - const result = await next(); - - const duration = Date.now() - now; - console.log("Server Req/Res:", { duration: `${duration}ms`, functionId }); - - return result; - } -); - export const authenticatedMiddleware = createMiddleware({ type: "function" }) .middleware([logMiddleware]) .server(async ({ next }) => { diff --git a/src/lib/server-logger.ts b/src/lib/server-logger.ts new file mode 100644 index 00000000..05164566 --- /dev/null +++ b/src/lib/server-logger.ts @@ -0,0 +1,70 @@ +import { createMiddleware } from "@tanstack/react-start"; + +// === SCOPE CONSTANTS === +export const LOG_SCOPES = { + DEFAULT: "default", + PAYMENTS: "payments", + AUTH: "auth", + AFFILIATES: "affiliates", + EARLY_ACCESS: "early-access", + APP_SETTINGS: "app-settings", +} as const; + +export type LogScope = (typeof LOG_SCOPES)[keyof typeof LOG_SCOPES]; + +// === CONFIG: Record === +export const logScopeConfig: Record = { + default: process.env.NODE_ENV === "development", + payments: true, // zawsze loguj płatności + auth: true, + affiliates: false, // wyłączone - za dużo szumu + "early-access": false, + "app-settings": false, +}; + +// === HELPERS === +function decodeBase64(str: string): string { + // URL-safe base64 -> standard base64 + const standardBase64 = str.replace(/-/g, "+").replace(/_/g, "/"); + return atob(standardBase64); +} + +function parseFunctionId(functionId: string): string { + try { + const decoded = JSON.parse(decodeBase64(functionId)); + return decoded.export?.replace(/_createServerFn_handler$/, "") ?? "unknown"; + } catch { + return "unknown"; + } +} + +// === FACTORY === +function createLogMiddlewareForScope(scope: LogScope = LOG_SCOPES.DEFAULT) { + return createMiddleware({ type: "function" }).server( + async ({ next, functionId }) => { + const isEnabled = logScopeConfig[scope] ?? logScopeConfig.default; + + if (!isEnabled) { + return next(); + } + + const functionName = parseFunctionId(functionId); + const start = Date.now(); + + const result = await next(); + + const duration = Date.now() - start; + console.log(`[${functionName}] ${duration}ms`); + + return result; + } + ); +} + +// === EXPORT: backward compat + callable === +const defaultMiddleware = createLogMiddlewareForScope(LOG_SCOPES.DEFAULT); + +export const logMiddleware = Object.assign( + (scope: LogScope) => createLogMiddlewareForScope(scope), + defaultMiddleware +); From 055085529d124a65a843bbe44fd584cd2fe319b3 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 18:54:07 +0000 Subject: [PATCH 13/28] chore: Update .env.sample with Stripe Connect client ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add STRIPE_CLIENT_ID for OAuth2 Connect flow - Add note about STRIPE_PRICE_ID being deprecated - Remove dev patches section (moved to .dev/) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .env.sample | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.env.sample b/.env.sample index 743b8e18..97c60484 100644 --- a/.env.sample +++ b/.env.sample @@ -9,8 +9,9 @@ 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 R2_ENDPOINT= R2_ACCESS_KEY_ID= @@ -30,12 +31,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 From 3518bee2b0ed60b0620e7f2e76252e416c858f34 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 23:38:55 +0000 Subject: [PATCH 14/28] feat(db): Add enums and CHECK constraint for affiliates - Add stripeAccountStatusEnum and affiliatePaymentMethodEnum - Add CHECK constraint ensuring discountRate <= commissionRate - Generate migrations 0051 and 0052 --- drizzle/0051_chilly_the_watchers.sql | 2 + drizzle/0052_funny_hardball.sql | 7 + drizzle/meta/0051_snapshot.json | 4513 +++++++++++++++++++++++++ drizzle/meta/0052_snapshot.json | 4538 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 14 + src/db/schema.ts | 24 +- 6 files changed, 9094 insertions(+), 4 deletions(-) create mode 100644 drizzle/0051_chilly_the_watchers.sql create mode 100644 drizzle/0052_funny_hardball.sql create mode 100644 drizzle/meta/0051_snapshot.json create mode 100644 drizzle/meta/0052_snapshot.json 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/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 bf36dce5..6abd9bfe 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -358,6 +358,20 @@ "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/src/db/schema.ts b/src/db/schema.ts index bdf90b0e..24a5bdba 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,7 +1,8 @@ -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { AnyPgColumn, boolean, + check, index, integer, pgEnum, @@ -247,6 +248,18 @@ export const attachments = tableCreator( ] ); +export const stripeAccountStatusEnum = pgEnum("stripe_account_status_enum", [ + "not_started", + "onboarding", + "active", + "restricted", +]); + +export const affiliatePaymentMethodEnum = pgEnum("affiliate_payment_method_enum", [ + "link", + "stripe", +]); + export const affiliates = tableCreator( "affiliate", { @@ -256,7 +269,7 @@ export const affiliates = tableCreator( .references(() => users.id, { onDelete: "cascade" }) .unique(), affiliateCode: text("affiliateCode").notNull().unique(), - paymentMethod: text("paymentMethod").notNull().default("link"), // 'link' or 'stripe' + paymentMethod: affiliatePaymentMethodEnum("paymentMethod").notNull().default("link"), paymentLink: text("paymentLink"), commissionRate: integer("commissionRate").notNull().default(30), // Discount rate: percentage of commission given to customer as discount @@ -268,7 +281,7 @@ export const affiliates = tableCreator( isActive: boolean("isActive").notNull().default(true), // Stripe Connect fields stripeConnectAccountId: text("stripeConnectAccountId"), - stripeAccountStatus: text("stripeAccountStatus").notNull().default("not_started"), // 'not_started', 'onboarding', 'active', 'restricted' + stripeAccountStatus: stripeAccountStatusEnum("stripeAccountStatus").notNull().default("not_started"), stripeChargesEnabled: boolean("stripeChargesEnabled").notNull().default(false), stripePayoutsEnabled: boolean("stripePayoutsEnabled").notNull().default(false), stripeDetailsSubmitted: boolean("stripeDetailsSubmitted").notNull().default(false), @@ -290,8 +303,10 @@ export const affiliates = tableCreator( }, (table) => [ index("affiliates_user_id_idx").on(table.userId), - index("affiliates_code_idx").on(table.affiliateCode), + // affiliateCode already has unique() constraint which creates implicit index uniqueIndex("affiliates_stripe_account_idx").on(table.stripeConnectAccountId), + // Ensure discountRate never exceeds commissionRate (would result in negative affiliate earnings) + check("discount_rate_check", sql`${table.discountRate} <= ${table.commissionRate} AND ${table.discountRate} >= 0`), ] ); @@ -320,6 +335,7 @@ export const affiliateReferrals = tableCreator( table.affiliateId, table.createdAt ), + index("referrals_affiliate_unpaid_idx").on(table.affiliateId, table.isPaid), index("referrals_purchaser_idx").on(table.purchaserId), uniqueIndex("referrals_stripe_session_unique").on(table.stripeSessionId), ] From 8f5e183f2c74a46a11418ecb686f15273af58478 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 23:39:18 +0000 Subject: [PATCH 15/28] feat(security): Add TOKEN_SIGNING_SECRET and URL sanitizer - Add dedicated TOKEN_SIGNING_SECRET env var for token signing - Create url-sanitizer.ts for XSS protection on URLs - Update crypto.ts to use new secret instead of STRIPE_WEBHOOK_SECRET --- .env.sample | 8 ++- src/utils/crypto.ts | 132 +++++++++++++++++++++++++++++++++++++ src/utils/env.ts | 5 +- src/utils/url-sanitizer.ts | 36 ++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 src/utils/url-sanitizer.ts diff --git a/.env.sample b/.env.sample index 97c60484..136430c0 100644 --- a/.env.sample +++ b/.env.sample @@ -13,6 +13,10 @@ STRIPE_PRICE_ID=your_stripe_price_id # likely no longer needed as we use dynamic 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= R2_SECRET_ACCESS_KEY= @@ -31,4 +35,6 @@ AWS_SES_ACCESS_KEY_ID= AWS_SES_SECRET_ACCESS_KEY= AWS_SES_REGION=us-east-1 -OPENAI_API_KEY= \ No newline at end of file +OPENAI_API_KEY= + +TOKEN_SIGNING_SECRET=CHANGE_ME \ No newline at end of file diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 1b846901..8e19a3d1 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -1,3 +1,34 @@ +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. * @@ -13,3 +44,104 @@ export function generateCsrfState(): string { "" ); } + +/** + * 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/env.ts b/src/utils/env.ts index 580498fe..749d495e 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -36,6 +36,9 @@ 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, @@ -46,6 +49,4 @@ export const env = { AWS_SES_SECRET_ACCESS_KEY: testFallback(process.env.AWS_SES_SECRET_ACCESS_KEY, "test-ses-secret-key", "AWS_SES_SECRET_ACCESS_KEY"), AWS_SES_REGION: process.env.AWS_SES_REGION || "us-east-1", FROM_EMAIL_ADDRESS: testFallback(process.env.FROM_EMAIL_ADDRESS, "test@example.com", "FROM_EMAIL_ADDRESS"), - // System user ID for automatic operations (e.g., automatic payouts) - SYSTEM_USER_ID: parseInt(process.env.SYSTEM_USER_ID || "1", 10), }; 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; +} From 79d10aaf99c48c41a45eb7f0379c4a90c0c1de07 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 23:39:39 +0000 Subject: [PATCH 16/28] feat(email): Add List-Unsubscribe header support - Refactor to use SendRawEmailCommand for MIME headers - Add List-Unsubscribe header for better email deliverability --- src/utils/email.ts | 100 ++++++++++++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/src/utils/email.ts b/src/utils/email.ts index e1304d7a..47aaaacb 100644 --- a/src/utils/email.ts +++ b/src/utils/email.ts @@ -1,7 +1,8 @@ -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"; @@ -22,6 +23,7 @@ export interface EmailOptions { subject: string; html: string; text?: string; + userId?: number; // Optional: if provided, adds List-Unsubscribe header } export interface EmailTemplate { @@ -30,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), }, }); @@ -171,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% @@ -180,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 @@ -275,10 +309,14 @@ export async function sendAffiliatePayoutSuccessEmail( ): 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}` @@ -302,10 +340,14 @@ export async function sendAffiliatePayoutFailedEmail( ): 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) { From 78006f17675b5e206b9e4d0ee4abed2950910f26 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 23:40:02 +0000 Subject: [PATCH 17/28] refactor(affiliates): Standardize server function response format - GET functions now return { success: true, data: ... } - Add feature flag middleware to Stripe account functions - Fix pagination in data-access layer --- src/data-access/affiliates.ts | 327 ++++++++++++++++++++++++++++------ src/fn/affiliates.ts | 121 +++++++------ src/fn/app-settings.ts | 2 + src/use-cases/affiliates.ts | 146 +++++++++------ 4 files changed, 432 insertions(+), 164 deletions(-) diff --git a/src/data-access/affiliates.ts b/src/data-access/affiliates.ts index 1dc4743f..ad48db5c 100644 --- a/src/data-access/affiliates.ts +++ b/src/data-access/affiliates.ts @@ -133,9 +133,23 @@ export async function getAffiliateStats(affiliateId: number) { export async function createAffiliatePayout( data: AffiliatePayoutCreate & { affiliateId: number; stripeTransferId?: string } ) { + // Validate inputs + if (!Number.isInteger(data.affiliateId) || data.affiliateId <= 0) { + throw new Error("Affiliate ID must be a positive integer"); + } + + if (!Number.isInteger(data.amount) || data.amount <= 0) { + throw new Error("Payout amount must be a positive integer (cents)"); + } + + if (data.paidBy === undefined || !Number.isInteger(data.paidBy) || data.paidBy <= 0) { + throw new Error("PaidBy (admin user ID) must be a positive integer"); + } + // Start a transaction to ensure consistency return await database.transaction(async (tx) => { // Get unpaid referrals to calculate total unpaid amount + // Order by createdAt to ensure oldest referrals are paid first (FIFO) const unpaidReferrals = await tx .select({ id: affiliateReferrals.id, @@ -147,7 +161,8 @@ export async function createAffiliatePayout( eq(affiliateReferrals.affiliateId, data.affiliateId), eq(affiliateReferrals.isPaid, false) ) - ); + ) + .orderBy(affiliateReferrals.createdAt); const totalUnpaidAmount = unpaidReferrals.reduce( (sum, referral) => sum + referral.commission, @@ -357,38 +372,41 @@ export async function updateAffiliateStripeAccount( lastStripeSync?: Date; } ) { - // If assigning a Stripe account, first clear it from any other affiliate - // This handles the case where the same Stripe account was previously connected - // to a different affiliate (e.g., during testing) - if (data.stripeConnectAccountId) { - await database + // Wrap in transaction to ensure atomicity when reassigning Stripe accounts + return await database.transaction(async (tx) => { + // If assigning a Stripe account, first clear it from any other affiliate + // This handles the case where the same Stripe account was previously connected + // to a different affiliate (e.g., during testing) + if (data.stripeConnectAccountId) { + await tx + .update(affiliates) + .set({ + stripeConnectAccountId: null, + stripeAccountStatus: "not_started", + stripeChargesEnabled: false, + stripePayoutsEnabled: false, + stripeDetailsSubmitted: false, + stripeAccountType: null, + updatedAt: new Date(), + }) + .where( + and( + eq(affiliates.stripeConnectAccountId, data.stripeConnectAccountId), + ne(affiliates.id, affiliateId) + ) + ); + } + + const [updated] = await tx .update(affiliates) .set({ - stripeConnectAccountId: null, - stripeAccountStatus: "not_started", - stripeChargesEnabled: false, - stripePayoutsEnabled: false, - stripeDetailsSubmitted: false, - stripeAccountType: null, + ...data, updatedAt: new Date(), }) - .where( - and( - eq(affiliates.stripeConnectAccountId, data.stripeConnectAccountId), - ne(affiliates.id, affiliateId) - ) - ); - } - - const [updated] = await database - .update(affiliates) - .set({ - ...data, - updatedAt: new Date(), - }) - .where(eq(affiliates.id, affiliateId)) - .returning(); - return updated; + .where(eq(affiliates.id, affiliateId)) + .returning(); + return updated; + }); } export async function getAffiliateByStripeAccountId(stripeAccountId: string) { @@ -572,21 +590,23 @@ export async function updateLastPayoutAttempt(affiliateId: number): Promise { - const affiliate = await getAffiliateById(affiliateId); - const currentCount = affiliate?.payoutRetryCount ?? 0; - const newCount = currentCount + 1; - - // Exponential backoff: 1 hour, 4 hours, 24 hours, then stop auto-retry - const backoffHours = [1, 4, 24]; - const nextRetryHours = backoffHours[Math.min(newCount - 1, backoffHours.length - 1)]; - const nextRetryAt = newCount <= 3 ? new Date(Date.now() + nextRetryHours * 60 * 60 * 1000) : null; - + // Use atomic SQL operations to prevent race conditions + // The CASE expression calculates the next retry time based on the NEW count + // Backoff: count 1 -> 1 hour, count 2 -> 4 hours, count 3 -> 24 hours, count > 3 -> null (stop) await database.update(affiliates) .set({ - payoutRetryCount: newCount, - nextPayoutRetryAt: nextRetryAt, + payoutRetryCount: sql`${affiliates.payoutRetryCount} + 1`, + nextPayoutRetryAt: sql` + CASE + WHEN ${affiliates.payoutRetryCount} + 1 = 1 THEN NOW() + INTERVAL '1 hour' + WHEN ${affiliates.payoutRetryCount} + 1 = 2 THEN NOW() + INTERVAL '4 hours' + WHEN ${affiliates.payoutRetryCount} + 1 = 3 THEN NOW() + INTERVAL '24 hours' + ELSE NULL + END + `, updatedAt: new Date() }) .where(eq(affiliates.id, affiliateId)); @@ -644,23 +664,73 @@ export async function isConnectAttemptRateLimited(affiliateId: number): Promise< /** * Record a connect attempt for rate limiting purposes. * Resets the count if the last attempt was more than an hour ago. + * Uses atomic SQL CASE to prevent TOCTOU race conditions. */ export async function recordConnectAttempt(affiliateId: number): Promise { - const affiliate = await getAffiliateById(affiliateId); - if (!affiliate) return; - - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - const shouldResetCount = !affiliate.lastConnectAttemptAt || affiliate.lastConnectAttemptAt < oneHourAgo; - + // Use atomic SQL CASE to determine whether to reset or increment + // This prevents TOCTOU race conditions by doing the check and update in one query await database.update(affiliates) .set({ lastConnectAttemptAt: new Date(), - connectAttemptCount: shouldResetCount ? 1 : sql`${affiliates.connectAttemptCount} + 1`, + connectAttemptCount: sql` + CASE + WHEN ${affiliates.lastConnectAttemptAt} IS NULL + OR ${affiliates.lastConnectAttemptAt} < NOW() - INTERVAL '1 hour' + THEN 1 + ELSE ${affiliates.connectAttemptCount} + 1 + END + `, updatedAt: new Date() }) .where(eq(affiliates.id, affiliateId)); } +/** + * Atomically check rate limit and record a connect attempt. + * Returns true if the attempt was allowed (not rate limited), false if rate limited. + * This function combines the check and record into a single atomic operation + * to prevent TOCTOU race conditions. + */ +export async function checkAndRecordConnectAttempt(affiliateId: number): Promise { + // Use a transaction to ensure atomicity + return await database.transaction(async (tx) => { + // Lock the row and get current state + const [affiliate] = await tx + .select({ + lastConnectAttemptAt: affiliates.lastConnectAttemptAt, + connectAttemptCount: affiliates.connectAttemptCount, + }) + .from(affiliates) + .where(eq(affiliates.id, affiliateId)) + .for("update"); // FOR UPDATE lock + + if (!affiliate) { + return false; + } + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const isExpired = !affiliate.lastConnectAttemptAt || affiliate.lastConnectAttemptAt < oneHourAgo; + const currentCount = isExpired ? 0 : affiliate.connectAttemptCount; + + // Check if rate limited BEFORE this attempt + if (currentCount >= 3) { + return false; + } + + // Record the attempt + const newCount = isExpired ? 1 : currentCount + 1; + await tx.update(affiliates) + .set({ + lastConnectAttemptAt: new Date(), + connectAttemptCount: newCount, + updatedAt: new Date() + }) + .where(eq(affiliates.id, affiliateId)); + + return true; + }); +} + /** * Create a pending payout record BEFORE initiating the Stripe transfer. * This is part of the two-phase payout approach to prevent money being @@ -674,6 +744,19 @@ export async function createPendingPayout(data: { amount: number; paidBy: number; }): Promise<{ id: number }> { + // Validate inputs + if (!Number.isInteger(data.affiliateId) || data.affiliateId <= 0) { + throw new Error("Affiliate ID must be a positive integer"); + } + + if (!Number.isInteger(data.amount) || data.amount <= 0) { + throw new Error("Payout amount must be a positive integer (cents)"); + } + + if (!Number.isInteger(data.paidBy) || data.paidBy <= 0) { + throw new Error("PaidBy (admin user ID) must be a positive integer"); + } + const [payout] = await database .insert(affiliatePayouts) .values({ @@ -698,11 +781,12 @@ export async function completePendingPayout( stripeTransferId: string ): Promise { await database.transaction(async (tx) => { - // Get the pending payout + // Get the pending payout with FOR UPDATE lock to prevent race conditions const [payout] = await tx .select() .from(affiliatePayouts) - .where(eq(affiliatePayouts.id, payoutId)); + .where(eq(affiliatePayouts.id, payoutId)) + .for("update"); if (!payout) { throw new Error(`Payout ${payoutId} not found`); @@ -732,7 +816,7 @@ export async function completePendingPayout( }) .where(eq(affiliates.id, payout.affiliateId)); - // Get unpaid referrals + // Get unpaid referrals ordered by createdAt (FIFO - oldest first) const unpaidReferrals = await tx .select({ id: affiliateReferrals.id, @@ -744,9 +828,11 @@ export async function completePendingPayout( eq(affiliateReferrals.affiliateId, payout.affiliateId), eq(affiliateReferrals.isPaid, false) ) - ); + ) + .orderBy(affiliateReferrals.createdAt); // Mark referrals as paid up to the payout amount + // Only mark referrals where full commission can be covered (consistent with createAffiliatePayout) let remainingPayout = payout.amount; const referralsToUpdate: number[] = []; @@ -754,12 +840,10 @@ export async function completePendingPayout( if (remainingPayout >= referral.commission) { referralsToUpdate.push(referral.id); remainingPayout -= referral.commission; - } else if (remainingPayout > 0) { - referralsToUpdate.push(referral.id); - remainingPayout = 0; + } else { + // Skip referrals that would be partially paid - only mark as paid if full commission can be covered + break; } - - if (remainingPayout === 0) break; } // Update the selected referrals as paid @@ -846,3 +930,134 @@ export async function updateAffiliateDiscountRate( .returning(); return updated; } + +/** + * Increment the paidAmount for an affiliate. + * Used when Stripe handles auto-transfer and we need to mark the commission as paid. + * @param affiliateId - The affiliate's ID + * @param amount - The amount to increment (in cents) + */ +export async function incrementAffiliatePaidAmount( + affiliateId: number, + amount: number +) { + const [updated] = await database + .update(affiliates) + .set({ + paidAmount: sql`${affiliates.paidAmount} + ${amount}`, + updatedAt: new Date(), + }) + .where(eq(affiliates.id, affiliateId)) + .returning(); + return updated; +} + +// Export the transaction type for use in use-cases +export type DatabaseTransaction = Parameters[0]>[0]; + +/** + * Get affiliate by code using a transaction. + * Used within transactions to maintain consistency. + */ +export async function getAffiliateByCodeTx( + tx: DatabaseTransaction, + code: string +) { + const [affiliate] = await tx + .select() + .from(affiliates) + .where( + and(eq(affiliates.affiliateCode, code), eq(affiliates.isActive, true)) + ); + return affiliate; +} + +/** + * Get affiliate referral by Stripe session ID using a transaction. + * Used within transactions to maintain consistency. + */ +export async function getAffiliateByStripeSessionTx( + tx: DatabaseTransaction, + stripeSessionId: string +) { + const [result] = await tx + .select({ + affiliate: affiliates, + referral: affiliateReferrals, + }) + .from(affiliateReferrals) + .innerJoin(affiliates, eq(affiliateReferrals.affiliateId, affiliates.id)) + .where(eq(affiliateReferrals.stripeSessionId, stripeSessionId)); + + return result; +} + +/** + * Create affiliate referral using a transaction. + * Used within transactions to maintain consistency. + */ +export async function createAffiliateReferralTx( + tx: DatabaseTransaction, + data: AffiliateReferralCreate +) { + const [referral] = await tx + .insert(affiliateReferrals) + .values(data) + .returning(); + return referral; +} + +/** + * Update affiliate balances using a transaction. + * Used within transactions to maintain consistency. + */ +export async function updateAffiliateBalancesTx( + tx: DatabaseTransaction, + affiliateId: number, + earnings: number, + unpaid: number +) { + // Validate inputs + if (!Number.isInteger(affiliateId) || affiliateId <= 0) { + throw new Error("Affiliate ID must be a positive integer"); + } + + if (!Number.isInteger(earnings) || earnings < 0) { + throw new Error("Earnings must be a non-negative integer"); + } + + if (!Number.isInteger(unpaid) || unpaid < 0) { + throw new Error("Unpaid amount must be a non-negative integer"); + } + + const [updated] = await tx + .update(affiliates) + .set({ + totalEarnings: sql`${affiliates.totalEarnings} + ${earnings}`, + unpaidBalance: sql`${affiliates.unpaidBalance} + ${unpaid}`, + updatedAt: new Date(), + }) + .where(eq(affiliates.id, affiliateId)) + .returning(); + return updated; +} + +/** + * Increment the paidAmount for an affiliate using a transaction. + * Used within transactions when Stripe handles auto-transfer. + */ +export async function incrementAffiliatePaidAmountTx( + tx: DatabaseTransaction, + affiliateId: number, + amount: number +) { + const [updated] = await tx + .update(affiliates) + .set({ + paidAmount: sql`${affiliates.paidAmount} + ${amount}`, + updatedAt: new Date(), + }) + .where(eq(affiliates.id, affiliateId)) + .returning(); + return updated; +} diff --git a/src/fn/affiliates.ts b/src/fn/affiliates.ts index 7e6e9318..c47ddf4c 100644 --- a/src/fn/affiliates.ts +++ b/src/fn/affiliates.ts @@ -47,7 +47,7 @@ const registerAffiliateSchema = z.object({ path: ["paymentLink"], }); -export const registerAffiliateFn = createServerFn() +export const registerAffiliateFn = createServerFn({ method: "POST" }) .middleware([authenticatedMiddleware, affiliatesFeatureMiddleware]) .inputValidator(registerAffiliateSchema) .handler(async ({ data, context }) => { @@ -56,36 +56,44 @@ export const registerAffiliateFn = createServerFn() paymentMethod: data.paymentMethod, paymentLink: data.paymentLink, }); - return affiliate; + return { success: true, data: affiliate }; }); -export const getAffiliateDashboardFn = createServerFn() +export const getAffiliateDashboardFn = createServerFn({ method: "GET" }) .middleware([authenticatedMiddleware, affiliatesFeatureMiddleware]) + .inputValidator(z.void()) .handler(async ({ context }) => { const analytics = await getAffiliateAnalyticsUseCase(context.userId); - return analytics; + return { success: true, data: analytics }; }); -export const checkIfUserIsAffiliateFn = createServerFn() +export const checkIfUserIsAffiliateFn = createServerFn({ method: "GET" }) .middleware([unauthenticatedMiddleware, affiliatesFeatureMiddleware]) + .inputValidator(z.void()) .handler(async ({ context }) => { if (!context.userId) { return { - isAffiliate: false, - isOnboardingComplete: false, - paymentMethod: null, - stripeAccountStatus: null, - hasStripeAccount: false, + success: true, + data: { + isAffiliate: false, + isOnboardingComplete: false, + paymentMethod: null, + stripeAccountStatus: null, + hasStripeAccount: false, + }, }; } const affiliate = await getAffiliateByUserId(context.userId); if (!affiliate) { return { - isAffiliate: false, - isOnboardingComplete: false, - paymentMethod: null, - stripeAccountStatus: null, - hasStripeAccount: false, + success: true, + data: { + isAffiliate: false, + isOnboardingComplete: false, + paymentMethod: null, + stripeAccountStatus: null, + hasStripeAccount: false, + }, }; } @@ -97,11 +105,14 @@ export const checkIfUserIsAffiliateFn = createServerFn() (affiliate.paymentMethod === "stripe" && affiliate.stripeAccountStatus === "active"); return { - isAffiliate: true, - isOnboardingComplete, - paymentMethod: affiliate.paymentMethod, - stripeAccountStatus: affiliate.stripeAccountStatus, - hasStripeAccount: !!affiliate.stripeConnectAccountId, + success: true, + data: { + isAffiliate: true, + isOnboardingComplete, + paymentMethod: affiliate.paymentMethod, + stripeAccountStatus: affiliate.stripeAccountStatus, + hasStripeAccount: !!affiliate.stripeConnectAccountId, + }, }; }); @@ -126,7 +137,7 @@ const updatePaymentMethodSchema = z.object({ path: ["paymentLink"], }); -export const updateAffiliatePaymentLinkFn = createServerFn() +export const updateAffiliatePaymentLinkFn = createServerFn({ method: "POST" }) .middleware([authenticatedMiddleware, affiliatesFeatureMiddleware]) .inputValidator(updatePaymentMethodSchema) .handler(async ({ data, context }) => { @@ -135,7 +146,7 @@ export const updateAffiliatePaymentLinkFn = createServerFn() paymentMethod: data.paymentMethod, paymentLink: data.paymentLink, }); - return updated; + return { success: true, data: updated }; }); const updateDiscountRateSchema = z.object({ @@ -146,7 +157,7 @@ const updateDiscountRateSchema = z.object({ * Update affiliate's discount rate (how much of their commission goes to customer discount). * Affiliates can call this to adjust their commission split. */ -export const updateAffiliateDiscountRateFn = createServerFn() +export const updateAffiliateDiscountRateFn = createServerFn({ method: "POST" }) .middleware([authenticatedMiddleware, affiliatesFeatureMiddleware]) .inputValidator(updateDiscountRateSchema) .handler(async ({ data, context }) => { @@ -159,14 +170,15 @@ export const updateAffiliateDiscountRateFn = createServerFn() throw new Error(`Discount rate cannot exceed your commission rate of ${affiliate.commissionRate}%`); } const updated = await updateAffiliateDiscountRate(affiliate.id, data.discountRate); - return updated; + return { success: true, data: updated }; }); -export const adminGetAllAffiliatesFn = createServerFn() +export const adminGetAllAffiliatesFn = createServerFn({ method: "GET" }) .middleware([adminMiddleware]) + .inputValidator(z.void()) .handler(async () => { const affiliates = await adminGetAllAffiliatesUseCase(); - return affiliates; + return { success: true, data: affiliates }; }); const toggleAffiliateStatusSchema = z.object({ @@ -174,7 +186,7 @@ const toggleAffiliateStatusSchema = z.object({ isActive: z.boolean(), }); -export const adminToggleAffiliateStatusFn = createServerFn() +export const adminToggleAffiliateStatusFn = createServerFn({ method: "POST" }) .middleware([adminMiddleware]) .inputValidator(toggleAffiliateStatusSchema) .handler(async ({ data }) => { @@ -182,7 +194,7 @@ export const adminToggleAffiliateStatusFn = createServerFn() affiliateId: data.affiliateId, isActive: data.isActive, }); - return updated; + return { success: true, data: updated }; }); const updateAffiliateCommissionRateSchema = z.object({ @@ -190,7 +202,7 @@ const updateAffiliateCommissionRateSchema = z.object({ commissionRate: z.number().min(0).max(100), }); -export const adminUpdateAffiliateCommissionRateFn = createServerFn() +export const adminUpdateAffiliateCommissionRateFn = createServerFn({ method: "POST" }) .middleware([adminMiddleware]) .inputValidator(updateAffiliateCommissionRateSchema) .handler(async ({ data }) => { @@ -198,7 +210,7 @@ export const adminUpdateAffiliateCommissionRateFn = createServerFn() affiliateId: data.affiliateId, commissionRate: data.commissionRate, }); - return updated; + return { success: true, data: updated }; }); const recordPayoutSchema = z.object({ @@ -209,7 +221,7 @@ const recordPayoutSchema = z.object({ notes: z.string().optional(), }); -export const adminRecordPayoutFn = createServerFn() +export const adminRecordPayoutFn = createServerFn({ method: "POST" }) .middleware([adminMiddleware]) .inputValidator(recordPayoutSchema) .handler(async ({ data, context }) => { @@ -221,24 +233,25 @@ export const adminRecordPayoutFn = createServerFn() notes: data.notes, paidBy: context.userId, }); - return payout; + return { success: true, data: payout }; }); const validateAffiliateCodeSchema = z.object({ code: z.string().min(1).max(20).regex(/^[A-Z0-9]+$/i, "Code must contain only letters and numbers"), }); -export const validateAffiliateCodeFn = createServerFn() +export const validateAffiliateCodeFn = createServerFn({ method: "GET" }) .middleware([unauthenticatedMiddleware]) .inputValidator(validateAffiliateCodeSchema) .handler(async ({ data }) => { const affiliate = await validateAffiliateCodeUseCase(data.code); - return { valid: !!affiliate }; + return { success: true, data: { valid: !!affiliate } }; }); // Admin function to trigger automatic payouts for all eligible affiliates -export const adminProcessAutomaticPayoutsFn = createServerFn() +export const adminProcessAutomaticPayoutsFn = createServerFn({ method: "POST" }) .middleware([adminMiddleware]) + .inputValidator(z.void()) .handler(async ({ context }) => { const result = await processAllAutomaticPayoutsUseCase({ systemUserId: context.userId, @@ -246,17 +259,20 @@ export const adminProcessAutomaticPayoutsFn = createServerFn() return { success: true, - message: `Processed ${result.processed} affiliates: ${result.successful} successful, ${result.failed} failed`, - processed: result.processed, - successful: result.successful, - failed: result.failed, - results: result.results, + data: { + message: `Processed ${result.processed} affiliates: ${result.successful} successful, ${result.failed} failed`, + processed: result.processed, + successful: result.successful, + failed: result.failed, + results: result.results, + }, }; }); // User function to manually refresh their Stripe Connect account status -export const refreshStripeAccountStatusFn = createServerFn() - .middleware([authenticatedMiddleware]) +export const refreshStripeAccountStatusFn = createServerFn({ method: "POST" }) + .middleware([authenticatedMiddleware, affiliatesFeatureMiddleware]) + .inputValidator(z.void()) .handler(async ({ context }) => { const result = await refreshStripeAccountStatusForUserUseCase(context.userId); @@ -266,20 +282,25 @@ export const refreshStripeAccountStatusFn = createServerFn() return { success: true, - message: "Stripe account status refreshed successfully", + data: { + message: "Stripe account status refreshed successfully", + }, }; }); // User function to disconnect their Stripe Connect account export const disconnectStripeAccountFn = createServerFn({ method: "POST" }) - .middleware([authenticatedMiddleware]) + .middleware([authenticatedMiddleware, affiliatesFeatureMiddleware]) + .inputValidator(z.void()) .handler(async ({ context }) => { const affiliate = await disconnectStripeAccountUseCase(context.userId); return { success: true, - message: "Stripe Connect account disconnected successfully", - affiliate, + data: { + message: "Stripe Connect account disconnected successfully", + affiliate, + }, }; }); @@ -290,7 +311,7 @@ const getAffiliatePayoutsSchema = z.object({ offset: z.number().optional().default(0), }); -export const adminGetAffiliatePayoutsFn = createServerFn() +export const adminGetAffiliatePayoutsFn = createServerFn({ method: "GET" }) .middleware([adminMiddleware]) .inputValidator(getAffiliatePayoutsSchema) .handler(async ({ data }) => { @@ -298,7 +319,7 @@ export const adminGetAffiliatePayoutsFn = createServerFn() limit: data.limit, offset: data.offset, }); - return result; + return { success: true, data: result }; }); // Admin function to get affiliate referral/conversion history @@ -308,7 +329,7 @@ const getAffiliateReferralsSchema = z.object({ offset: z.number().optional().default(0), }); -export const adminGetAffiliateReferralsFn = createServerFn() +export const adminGetAffiliateReferralsFn = createServerFn({ method: "GET" }) .middleware([adminMiddleware]) .inputValidator(getAffiliateReferralsSchema) .handler(async ({ data }) => { @@ -316,5 +337,5 @@ export const adminGetAffiliateReferralsFn = createServerFn() limit: data.limit, offset: data.offset, }); - return result; + return { success: true, data: result }; }); diff --git a/src/fn/app-settings.ts b/src/fn/app-settings.ts index fbfb8ec3..1e84f06b 100644 --- a/src/fn/app-settings.ts +++ b/src/fn/app-settings.ts @@ -193,6 +193,7 @@ export const getAffiliateCommissionRateFn = createServerFn({ method: "GET" }) */ export const getPublicAffiliateCommissionRateFn = createServerFn({ method: "GET" }) .middleware([unauthenticatedMiddleware]) + .inputValidator(z.void()) .handler(async () => { return getAffiliateCommissionRateUseCase(); }); @@ -223,6 +224,7 @@ export const getAffiliateMinimumPayoutFn = createServerFn({ method: "GET" }) */ export const getPublicAffiliateMinimumPayoutFn = createServerFn({ method: "GET" }) .middleware([unauthenticatedMiddleware]) + .inputValidator(z.void()) .handler(async () => { return getAffiliateMinimumPayoutUseCase(); }); diff --git a/src/use-cases/affiliates.ts b/src/use-cases/affiliates.ts index 325783f7..693c9d7e 100644 --- a/src/use-cases/affiliates.ts +++ b/src/use-cases/affiliates.ts @@ -3,10 +3,7 @@ import { createAffiliate, getAffiliateByUserId, getAffiliateByCode, - createAffiliateReferral, - updateAffiliateBalances, createAffiliatePayout, - getAffiliateByStripeSession, updateAffiliateProfile, getAffiliateStats, getAffiliateReferrals, @@ -27,6 +24,12 @@ import { createPendingPayout, completePendingPayout, failPendingPayout, + // Transaction-aware functions for processAffiliateReferralUseCase + getAffiliateByCodeTx, + getAffiliateByStripeSessionTx, + createAffiliateReferralTx, + updateAffiliateBalancesTx, + incrementAffiliatePaidAmountTx, } from "~/data-access/affiliates"; import { getAffiliateCommissionRate, getAffiliateMinimumPayout } from "~/data-access/app-settings"; import { ApplicationError } from "./errors"; @@ -36,6 +39,40 @@ 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, @@ -56,22 +93,7 @@ export async function registerAffiliateUseCase({ // Validate payment link if using link method if (paymentMethod === "link") { - 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" - ); - } + validatePaymentLink(paymentLink); } // Generate unique affiliate code @@ -126,21 +148,7 @@ export async function updateAffiliatePaymentLinkUseCase({ // Validate payment link if using link method if (paymentMethod === "link") { - if (!paymentLink || paymentLink.length < 10) { - throw new ApplicationError( - "Please provide a valid payment link", - "INVALID_PAYMENT_LINK" - ); - } - - try { - new URL(paymentLink); - } catch { - throw new ApplicationError( - "Payment link must be a valid URL", - "INVALID_PAYMENT_LINK" - ); - } + validatePaymentLink(paymentLink); } // Update payment method and link in database @@ -157,6 +165,15 @@ export async function adminToggleAffiliateStatusUseCase({ 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 }); } @@ -167,6 +184,15 @@ export async function adminUpdateAffiliateCommissionRateUseCase({ 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", @@ -174,7 +200,6 @@ export async function adminUpdateAffiliateCommissionRateUseCase({ ); } - const { updateAffiliateCommissionRate } = await import("~/data-access/affiliates"); return updateAffiliateCommissionRate(affiliateId, commissionRate); } @@ -255,8 +280,8 @@ export async function processAffiliateReferralUseCase({ 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) { logger.warn("Invalid affiliate code for purchase", { fn: "processAffiliateReferralUseCase", @@ -276,8 +301,9 @@ export async function processAffiliateReferralUseCase({ 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) { logger.warn("Duplicate Stripe session already processed", { fn: "processAffiliateReferralUseCase", @@ -291,9 +317,9 @@ export async function processAffiliateReferralUseCase({ const effectiveCommissionRate = frozenCommissionRate ?? affiliate.commissionRate; const commission = Math.floor((amount * effectiveCommissionRate) / 100); - // Create referral record with frozen rates for audit trail + // 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 createAffiliateReferral({ + const referral = await createAffiliateReferralTx(tx, { affiliateId: affiliate.id, purchaserId, stripeSessionId, @@ -306,25 +332,17 @@ export async function processAffiliateReferralUseCase({ isPaid: isAutoTransfer, // Paid immediately if Stripe handles transfer }); - // Update affiliate balances + // 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 and paidAmount (balance stays same) - await updateAffiliateBalances(affiliate.id, commission, 0); - // Also increment paidAmount since Stripe will transfer it - const { affiliates } = await import("~/db/schema"); - const { eq, sql } = await import("drizzle-orm"); - const { database } = await import("~/db"); - await database.update(affiliates) - .set({ - paidAmount: sql`${affiliates.paidAmount} + ${commission}`, - updatedAt: new Date() - }) - .where(eq(affiliates.id, affiliate.id)); + // 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 updateAffiliateBalances(affiliate.id, commission, commission); + await updateAffiliateBalancesTx(tx, affiliate.id, commission, commission); } return referral; @@ -346,6 +364,18 @@ export async function recordAffiliatePayoutUseCase({ notes?: string; paidBy: number; }) { + // 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( + "Affiliate account is not active", + "AFFILIATE_INACTIVE" + ); + } + // Validate minimum payout (configurable via admin settings) const minimumPayout = await getAffiliateMinimumPayout(); if (amount < minimumPayout) { @@ -447,9 +477,9 @@ export async function processAutomaticPayoutsUseCase({ // Phase 2: Create Stripe Transfer to connected account try { - // Create idempotency key to prevent duplicate payouts within same time window (1-minute buckets) - // Include the pending payout ID for additional uniqueness - const idempotencyKey = `payout-${affiliate.id}-${pendingPayout.id}-${payoutAmount}-${Math.floor(Date.now() / 60000)}`; + // 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( { From 969a48fe1a01126d9aeac67b5d3c3e62c08a187d Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 23:40:26 +0000 Subject: [PATCH 18/28] fix(affiliates): Update UI to use new response format and fix bugs - Update all consumers to access .data from server responses - Fix Rate input width (w-30) to fit 100% - Fix onboarding flow to prevent re-registration of existing affiliates --- src/components/discount-dialog.tsx | 4 +- src/routes/-components/header.tsx | 3 +- .../affiliate-details-sheet.tsx | 31 ++++- .../affiliates-columns.tsx | 7 +- src/routes/admin/affiliates.tsx | 48 +++++--- src/routes/affiliate-dashboard.tsx | 112 ++++++------------ src/routes/affiliate-onboarding.tsx | 24 +++- src/routes/affiliates.tsx | 3 +- 8 files changed, 126 insertions(+), 106 deletions(-) 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/routes/-components/header.tsx b/src/routes/-components/header.tsx index df9b8b56..2f364259 100644 --- a/src/routes/-components/header.tsx +++ b/src/routes/-components/header.tsx @@ -507,11 +507,12 @@ export function Header({ hasBanner = false }: { hasBanner?: boolean }) { }; // Check if user is an affiliate (only for authenticated users) - const { data: affiliateStatus } = useQuery({ + const { data: affiliateStatusResponse } = useQuery({ queryKey: ["user", "isAffiliate"], queryFn: () => checkIfUserIsAffiliateFn(), enabled: !!user && !user.isAdmin, }); + const affiliateStatus = affiliateStatusResponse?.data; const { isEnabled: agentsFeatureEnabled } = useFeatureFlag("AGENTS_FEATURE"); const { isEnabled: affiliatesFeatureEnabled } = useFeatureFlag("AFFILIATES_FEATURE"); diff --git a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx index 7cdaf057..fa2c5454 100644 --- a/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx +++ b/src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx @@ -25,6 +25,7 @@ import { } from "lucide-react"; import { NumberInputWithControls } from "~/components/blocks/number-input-with-controls"; import { cn } from "~/lib/utils"; +import { sanitizeImageUrl } from "~/utils/url-sanitizer"; import type { AffiliateRow } from "./affiliates-columns"; import { adminToggleAffiliateStatusFn, @@ -76,7 +77,7 @@ export function AffiliateDetailsSheet({ const [newCommissionRate, setNewCommissionRate] = useState(""); // Fetch payout history when sheet is open - const { data: payoutsData } = useQuery({ + const { data: payoutsResponse, isLoading: isLoadingPayouts, error: payoutsError } = useQuery({ queryKey: ["affiliatePayouts", affiliate?.id], queryFn: () => adminGetAffiliatePayoutsFn({ data: { affiliateId: affiliate!.id } @@ -85,7 +86,7 @@ export function AffiliateDetailsSheet({ }); // Fetch referral/conversion history when sheet is open - const { data: referralsData } = useQuery({ + const { data: referralsResponse, isLoading: isLoadingReferrals, error: referralsError } = useQuery({ queryKey: ["affiliateReferrals", affiliate?.id], queryFn: () => adminGetAffiliateReferralsFn({ data: { affiliateId: affiliate!.id } @@ -93,7 +94,12 @@ export function AffiliateDetailsSheet({ enabled: open && !!affiliate?.id, }); + const isLoadingActivity = isLoadingPayouts || isLoadingReferrals; + const activityError = payoutsError || referralsError; + // Simple data access - pagination can be added later if needed + const payoutsData = payoutsResponse?.data; + const referralsData = referralsResponse?.data; const allReferrals = referralsData?.items ?? []; const allPayouts = payoutsData?.items ?? []; const hasMoreActivity = (referralsData?.hasMore ?? false) || (payoutsData?.hasMore ?? false); @@ -170,9 +176,9 @@ export function AffiliateDetailsSheet({
{/* Avatar */}
- {affiliate.userImage ? ( + {sanitizeImageUrl(affiliate.userImage) ? ( {displayName} @@ -362,7 +368,23 @@ export function AffiliateDetailsSheet({ {/* 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) => ( @@ -433,6 +455,7 @@ export function AffiliateDetailsSheet({
)}
+ )}
diff --git a/src/routes/admin/-affiliates-components/affiliates-columns.tsx b/src/routes/admin/-affiliates-components/affiliates-columns.tsx index 99dfdb0e..be7ca781 100644 --- a/src/routes/admin/-affiliates-components/affiliates-columns.tsx +++ b/src/routes/admin/-affiliates-components/affiliates-columns.tsx @@ -12,6 +12,7 @@ import { Link, } from "lucide-react"; import { cn } from "~/lib/utils"; +import { sanitizeImageUrl } from "~/utils/url-sanitizer"; export type AffiliateRow = { id: number; @@ -74,9 +75,9 @@ export function getAffiliateColumns(
{/* Avatar with status indicator */}
- {affiliate.userImage ? ( + {sanitizeImageUrl(affiliate.userImage) ? ( {displayName} @@ -274,6 +275,7 @@ export function getAffiliateColumns( onClick={() => options.onToggleStatus(affiliate.id, affiliate.isActive) } + aria-label={affiliate.isActive ? `Suspend partner ${affiliate.userName || affiliate.affiliateCode}` : `Activate partner ${affiliate.userName || affiliate.affiliateCode}`} title={affiliate.isActive ? "Suspend Partner" : "Activate Partner"} > {affiliate.isActive ? ( @@ -287,6 +289,7 @@ export function getAffiliateColumns( size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" onClick={() => options.onViewDetails?.(affiliate)} + aria-label={`View details for ${affiliate.userName || affiliate.affiliateCode}`} title="View Details" > diff --git a/src/routes/admin/affiliates.tsx b/src/routes/admin/affiliates.tsx index ac2a4526..bf336122 100644 --- a/src/routes/admin/affiliates.tsx +++ b/src/routes/admin/affiliates.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; 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"; @@ -80,7 +81,7 @@ function useCommissionRate() { const [localRate, setLocalRate] = useState(""); const [hasLocalChanges, setHasLocalChanges] = useState(false); - const { data: commissionRate, isLoading } = useQuery({ + const { data: commissionRate, isLoading, error } = useQuery({ queryKey: ["affiliateCommissionRate"], queryFn: () => getAffiliateCommissionRateFn(), }); @@ -198,6 +199,7 @@ function useMinimumPayout() { } export const Route = createFileRoute("/admin/affiliates")({ + beforeLoad: () => assertIsAdminFn(), loader: ({ context }) => { context.queryClient.ensureQueryData({ queryKey: ["admin", "affiliates"], @@ -235,10 +237,11 @@ function AdminAffiliates() { return () => clearTimeout(timer); }, [searchQuery]); - const { data: affiliates, isLoading } = useQuery({ + const { data: affiliatesResponse, isLoading } = useQuery({ queryKey: ["admin", "affiliates"], queryFn: () => adminGetAllAffiliatesFn(), }); + const affiliates = affiliatesResponse?.data; // Keep selectedAffiliate in sync when affiliates list is refreshed useEffect(() => { @@ -331,27 +334,27 @@ function AdminAffiliates() { }, }); - const handleToggleStatus = async ( + const handleToggleStatus = useCallback(async ( affiliateId: number, currentStatus: boolean ) => { await toggleStatusMutation.mutateAsync({ data: { affiliateId, isActive: !currentStatus }, }); - }; + }, [toggleStatusMutation]); const handleTriggerAutoPayouts = async () => { await autoPayoutMutation.mutateAsync(); }; - const openPayoutDialog = (affiliate: AffiliateRow) => { + const openPayoutDialog = useCallback((affiliate: AffiliateRow) => { setPayoutAffiliateId(affiliate.id); setPayoutAffiliateName( affiliate.userName || affiliate.userEmail || "Unknown" ); setPayoutUnpaidBalance(affiliate.unpaidBalance); form.setValue("amount", affiliate.unpaidBalance / 100); - }; + }, [form]); const onSubmitPayout = async (values: PayoutFormValues) => { if (!payoutAffiliateId) return; @@ -374,12 +377,12 @@ function AdminAffiliates() { }).format(cents / 100); }; - 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( @@ -392,20 +395,28 @@ 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: (link) => window.open(link, "_blank"), + onViewLink: handleViewLink, onRecordPayout: openPayoutDialog, onToggleStatus: handleToggleStatus, - onViewDetails: (affiliate) => { - setSelectedAffiliate(affiliate); - setDetailsSheetOpen(true); - }, + onViewDetails: handleViewDetails, }), - [] + [copyToClipboard, handleViewLink, openPayoutDialog, handleToggleStatus, handleViewDetails] ); // Data table setup @@ -476,7 +487,7 @@ function AdminAffiliates() { disabled={commissionRateState.isLoading} isPending={commissionRateState.isPending} hasChanges={commissionRateState.hasLocalChanges} - inputWidth="w-24" + inputWidth="w-30" /> {/* Batch Settle Button */} @@ -591,7 +602,12 @@ function AdminAffiliates() { {/* Payout Dialog */} !open && setPayoutAffiliateId(null)} + onOpenChange={(open) => { + if (!open) { + setPayoutAffiliateId(null); + form.reset(); + } + }} > diff --git a/src/routes/affiliate-dashboard.tsx b/src/routes/affiliate-dashboard.tsx index c19a0507..eeaae57f 100644 --- a/src/routes/affiliate-dashboard.tsx +++ b/src/routes/affiliate-dashboard.tsx @@ -26,6 +26,7 @@ import { disconnectStripeAccountFn, updateAffiliateDiscountRateFn, } from "~/fn/affiliates"; +import { getPricingSettingsFn } from "~/fn/app-settings"; import { authenticatedMiddleware } from "~/lib/auth"; import { Copy, @@ -54,7 +55,7 @@ import { } from "lucide-react"; import { cn } from "~/lib/utils"; import { env } from "~/utils/env"; -import { AFFILIATE_CONFIG } from "~/config"; +import { AFFILIATE_CONFIG, PRICING_CONFIG } from "~/config"; import { Dialog, DialogContent, @@ -176,7 +177,7 @@ export const Route = createFileRoute("/affiliate-dashboard")({ await assertAuthenticatedFn(); // Check if onboarding is complete - redirect if not - const affiliateCheck = await checkIfUserIsAffiliateFn(); + const { data: affiliateCheck } = await checkIfUserIsAffiliateFn(); if (!affiliateCheck.isAffiliate) { throw redirect({ to: "/affiliates" }); } @@ -185,8 +186,8 @@ export const Route = createFileRoute("/affiliate-dashboard")({ } }, loader: async ({ context }) => { - // Fetch dashboard data and feature flags in parallel - const [data, discountSplitEnabled, customPaymentLinkEnabled] = await Promise.all([ + // 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(), @@ -199,13 +200,18 @@ export const Route = createFileRoute("/affiliate-dashboard")({ queryKey: ["featureFlag", "AFFILIATE_CUSTOM_PAYMENT_LINK"], queryFn: () => isFeatureEnabledForUserFn({ data: { flagKey: "AFFILIATE_CUSTOM_PAYMENT_LINK" } }), }), + context.queryClient.ensureQueryData({ + queryKey: ["pricing", "settings"], + queryFn: () => getPricingSettingsFn(), + }), ]); return { isAffiliate: true, - dashboard: data, + dashboard: dashboardResponse.data, discountSplitEnabled, customPaymentLinkEnabled, + pricingSettings, }; }, component: AffiliateDashboard, @@ -214,15 +220,12 @@ export const Route = createFileRoute("/affiliate-dashboard")({ function AffiliateDashboard() { const loaderData = Route.useLoaderData(); - // If user is not an affiliate, show error message - if (!loaderData.isAffiliate || !loaderData.dashboard) { - return ; - } - - // Use the dashboard data and feature flags from loader - const dashboard = loaderData.dashboard; + // 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(); @@ -443,7 +446,11 @@ function AffiliateDashboard() { readOnly className="font-mono text-sm" /> -

You earn

- ${((199 * (dashboard.affiliate.commissionRate - localDiscountRate)) / 100).toFixed(0)} + ${((pricingSettings.currentPrice * (dashboard.affiliate.commissionRate - localDiscountRate)) / 100).toFixed(0)}

per sale

@@ -832,15 +839,21 @@ function AffiliateDashboard() { Payment Link: - - {dashboard.affiliate.paymentLink} - - + {dashboard.affiliate.paymentLink.startsWith("https://") ? ( + + {dashboard.affiliate.paymentLink} + + + ) : ( + + {dashboard.affiliate.paymentLink} + + )}
)} @@ -1197,52 +1210,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 index 5ddd337c..b3492c31 100644 --- a/src/routes/affiliate-onboarding.tsx +++ b/src/routes/affiliate-onboarding.tsx @@ -74,7 +74,7 @@ export const Route = createFileRoute("/affiliate-onboarding")({ beforeLoad: () => assertFeatureEnabled("AFFILIATES_FEATURE"), loader: async ({ context }) => { // Check if user is already an affiliate (always fetch fresh data) - const affiliateCheck = await context.queryClient.fetchQuery({ + const { data: affiliateCheck } = await context.queryClient.fetchQuery({ queryKey: ["affiliate", "check"], queryFn: () => checkIfUserIsAffiliateFn(), }); @@ -123,7 +123,6 @@ function AffiliateOnboarding() { // Determine initial step based on URL params and affiliate status const getInitialStep = (): WizardStep => { - if (search.step) return search.step; if (search.stripeComplete) return "complete"; // Return from Stripe = complete // If onboarding is complete, show complete step @@ -138,11 +137,18 @@ function AffiliateOnboarding() { } // 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"; } - return "terms"; // New users start with terms + // New users: respect step param or start with terms + if (search.step) return search.step; + return "terms"; }; const [currentStep, setCurrentStep] = useState(getInitialStep); @@ -342,9 +348,11 @@ function AffiliateOnboarding() { {/* Stripe Express */}
- Page {table.getState().pagination.pageIndex + 1} of{" "} - {table.getPageCount()} + {table.getPageCount() > 0 + ? `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}` + : "No pages"}
+ {/* Batch Settle Button - only for Payment Link affiliates */} + + +
@@ -599,7 +604,8 @@ function AdminAffiliates() { onOpenChange={setDetailsSheetOpen} /> - {/* Payout Dialog */} + {/* Payout Dialog - only shown when custom payment links are enabled */} + { @@ -728,6 +734,7 @@ function AdminAffiliates() { + ); } From c69cb3d5bf19bc7ff4814a5c95571beffea16fd1 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sun, 28 Dec 2025 03:58:47 +0000 Subject: [PATCH 23/28] fix(affiliates): Fix TypeScript errors and align Stripe status types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'pending' from stripeAccountStatus type (not in DB schema) - Add updatePaymentMethodFn export alias for backward compatibility - Add missing updateAffiliateCommissionRate import - Fix refreshStripeStatusMutation call to use { data: undefined } - Update determineStripeAccountStatus return type to StripeAccountStatusType 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/data-access/affiliates.ts | 2 +- src/fn/affiliates.ts | 3 +++ src/routes/affiliate-dashboard.tsx | 2 +- src/use-cases/affiliates.ts | 1 + src/utils/stripe-status.ts | 11 +++++------ 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/data-access/affiliates.ts b/src/data-access/affiliates.ts index ad48db5c..a2c4fe18 100644 --- a/src/data-access/affiliates.ts +++ b/src/data-access/affiliates.ts @@ -364,7 +364,7 @@ export async function updateAffiliateStripeAccount( affiliateId: number, data: { stripeConnectAccountId?: string; - stripeAccountStatus?: string; + stripeAccountStatus?: "not_started" | "onboarding" | "active" | "restricted"; stripeChargesEnabled?: boolean; stripePayoutsEnabled?: boolean; stripeDetailsSubmitted?: boolean; diff --git a/src/fn/affiliates.ts b/src/fn/affiliates.ts index c47ddf4c..0a66828e 100644 --- a/src/fn/affiliates.ts +++ b/src/fn/affiliates.ts @@ -339,3 +339,6 @@ export const adminGetAffiliateReferralsFn = createServerFn({ method: "GET" }) }); return { success: true, data: result }; }); + +// Alias for backward compatibility +export const updatePaymentMethodFn = updateAffiliatePaymentLinkFn; diff --git a/src/routes/affiliate-dashboard.tsx b/src/routes/affiliate-dashboard.tsx index eeaae57f..e3aa2c60 100644 --- a/src/routes/affiliate-dashboard.tsx +++ b/src/routes/affiliate-dashboard.tsx @@ -819,7 +819,7 @@ function AffiliateDashboard() {

)} diff --git a/src/routes/api/connect/stripe/callback/index.ts b/src/routes/api/connect/stripe/callback/index.ts index 92ea9f90..078d31b4 100644 --- a/src/routes/api/connect/stripe/callback/index.ts +++ b/src/routes/api/connect/stripe/callback/index.ts @@ -57,12 +57,14 @@ export const Route = createFileRoute("/api/connect/stripe/callback/")({ return new Response("Invalid state parameter", { status: 400 }); } - // Clear cookies after successful validation - deleteCookie("stripe_connect_state"); - deleteCookie("stripe_connect_affiliate_id"); - if (onboardingInProgress) { - deleteCookie("affiliate_onboarding"); - } + // 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 @@ -72,15 +74,19 @@ export const Route = createFileRoute("/api/connect/stripe/callback/")({ 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 }); } @@ -102,6 +108,9 @@ export const Route = createFileRoute("/api/connect/stripe/callback/")({ 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 @@ -113,6 +122,7 @@ export const Route = createFileRoute("/api/connect/stripe/callback/")({ }); } 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/purchase.tsx b/src/routes/purchase.tsx index 3a540dd8..411cbc26 100644 --- a/src/routes/purchase.tsx +++ b/src/routes/purchase.tsx @@ -32,6 +32,15 @@ import { shouldShowEarlyAccessFn } from "~/fn/early-access"; import { useAnalytics } from "~/hooks/use-analytics"; import { trackPurchaseIntentFn } from "~/fn/analytics"; 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(), @@ -86,6 +95,7 @@ export const Route = createFileRoute("/purchase")({ return { shouldShowEarlyAccess, affiliateInfo, pricing }; }, component: RouteComponent, + errorComponent: DefaultCatchBoundary, }); const checkoutSchema = z.object({ @@ -172,12 +182,12 @@ const checkoutFn = createServerFn() const currentPriceDollars = await getPricingCurrentPrice(); // Calculate the final price (in cents) - const basePriceInCents = currentPriceDollars * 100; + const basePriceInCents = currentPriceDollars * CENTS_PER_DOLLAR; let finalPriceInCents = basePriceInCents; // Apply affiliate discount if set if (affiliateDiscount > 0) { - finalPriceInCents = Math.round(basePriceInCents * (1 - affiliateDiscount / 100)); + finalPriceInCents = Math.round(basePriceInCents * (1 - affiliateDiscount / PERCENTAGE_DIVISOR)); } const sessionConfig: Stripe.Checkout.SessionCreateParams = { @@ -205,7 +215,7 @@ const checkoutFn = createServerFn() // 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) / 100); + const commissionAmountCents = Math.floor((finalPriceInCents * affiliateCommission) / PERCENTAGE_DIVISOR); sessionConfig.payment_intent_data = { transfer_data: { destination: affiliateStripeAccountId, @@ -273,9 +283,31 @@ function RouteComponent() { // Calculate discounted price if affiliate has discount const hasAffiliateDiscount = affiliateInfo && affiliateInfo.discountRate > 0; const discountedPrice = hasAffiliateDiscount - ? Math.round(pricing.currentPrice * (1 - affiliateInfo.discountRate / 100)) + ? 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) @@ -300,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 @@ -321,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 (